├── .github └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.rst ├── coconuts ├── __init__.py ├── fixtures │ └── test_users.json ├── forms.py ├── locale │ └── fr │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── urls.py └── views.py ├── frontend ├── .editorconfig ├── .gitignore ├── .vscode │ ├── extensions.json │ ├── launch.json │ └── tasks.json ├── README.md ├── angular.json ├── eslint.config.js ├── karma.conf.js ├── package-lock.json ├── package.json ├── src │ ├── app │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.spec.ts │ │ ├── app.component.ts │ │ ├── app.config.ts │ │ ├── file-icon.pipe.spec.ts │ │ ├── file-icon.pipe.ts │ │ ├── file-size.pipe.spec.ts │ │ ├── file-size.pipe.ts │ │ ├── file.service.spec.ts │ │ ├── file.service.ts │ │ ├── router-link.directive.spec.ts │ │ ├── router-link.directive.ts │ │ ├── router.service.spec.ts │ │ └── router.service.ts │ ├── index.html │ ├── main.ts │ └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json ├── manage.py ├── pyproject.toml ├── requirements.txt └── tests ├── __init__.py ├── data ├── .test.txt ├── test.jpg ├── test.mp4 ├── test.png ├── test.txt ├── test_finepix.jpg ├── test_portrait.jpg ├── test_portrait.mp4 ├── test_rotated.jpg └── test_rotated.mp4 ├── settings.py ├── templates └── registration │ └── login.html ├── test_content.py ├── test_download.py ├── test_exif.py ├── test_index.py ├── test_path.py ├── test_render.py └── urls.py /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | backend: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python: 11 | - '3.13' 12 | - '3.12' 13 | - '3.11' 14 | - '3.10' 15 | - '3.9' 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up FFmpeg 19 | run: | 20 | sudo apt-get -yq update 21 | sudo apt-get -yq install ffmpeg 22 | - uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python }} 25 | - name: Install packages 26 | run: | 27 | pip install -r requirements.txt coverage[toml] ruff 28 | - name: Run linters 29 | run: | 30 | ruff check . 31 | ruff format --check --diff . 32 | - name: Run test suite 33 | run: | 34 | coverage run ./manage.py test 35 | - uses: codecov/codecov-action@v4 36 | with: 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | 39 | frontend: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: actions/setup-node@v4 44 | with: 45 | node-version: 20 46 | - name: Run test suite 47 | run: | 48 | cd frontend 49 | npm install 50 | npm test -- --browsers=ChromeHeadless --code-coverage --watch=false 51 | - uses: codecov/codecov-action@v4 52 | with: 53 | token: ${{ secrets.CODECOV_TOKEN }} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | *.pyc 3 | /env 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Jeremy Lainé. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/jlaine/django-coconuts/workflows/tests/badge.svg 2 | :target: https://github.com/jlaine/django-coconuts/actions 3 | :alt: Tests 4 | 5 | .. image:: https://img.shields.io/codecov/c/github/jlaine/django-coconuts.svg 6 | :target: https://codecov.io/github/jlaine/django-coconuts 7 | :alt: Coverage 8 | 9 | What is ``Coconuts``? 10 | --------------------- 11 | 12 | Coconuts is a simple photo sharing web application. The backend is written in 13 | Python, using the Django framework. The frontend is written in TypeScript 14 | using the Angular framework. 15 | 16 | Some of Coconuts' features: 17 | 18 | * **authentication**: Coconuts uses Django's user system and you can create and 19 | manage your users with the admin interface. 20 | * **easy to manage**: photos and albums are simply stored as files and 21 | directories on the server 22 | * **thumbnails**: thumbnails are automatically generated as users browse albums 23 | * **touch friendly**: Coconuts features a clean and simple user interface which 24 | works well on tablets and smartphones. You can swipe between photos. 25 | 26 | Using ``Coconuts`` 27 | ------------------ 28 | 29 | To make use of Coconuts, you first need to create a Django project. 30 | 31 | You need to define the following settings: 32 | 33 | * ``INSTALLED_APPS``: add `"coconuts"` to the list of installed applications 34 | 35 | * ``COCONUTS_DATA_ROOT``: absolute path to the directory that holds photos. 36 | 37 | * ``COCONUTS_CACHE_ROOT``: absolute path to the directory that holds cache. 38 | 39 | * ``COCONUTS_FRONTEND_ROOT``: absolute path to the directory that holds the 40 | compiled frontend. 41 | 42 | Finally you need to include somewhere in your ``urls.py``: 43 | 44 | .. code:: python 45 | 46 | path("somepath/", include("coconuts.urls")), 47 | -------------------------------------------------------------------------------- /coconuts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlaine/django-coconuts/67ef63ebc8a32b2b2343e5cfa37359a50b7ae826/coconuts/__init__.py -------------------------------------------------------------------------------- /coconuts/fixtures/test_users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "auth.group", 5 | "fields": { 6 | "name": "Test group 1", 7 | "permissions": [] 8 | } 9 | }, 10 | { 11 | "pk": 1, 12 | "model": "auth.user", 13 | "fields": { 14 | "username": "test_user_1", 15 | "first_name": "", 16 | "last_name": "", 17 | "is_active": true, 18 | "is_superuser": false, 19 | "is_staff": false, 20 | "last_login": "2013-03-29T07:18:23.300Z", 21 | "groups": [], 22 | "user_permissions": [], 23 | "password": "pbkdf2_sha256$10000$kPQEb7eP4BCm$0ONJsdIOozRYpvujoSYYiKCbJVVKXDAiVYgKNEDDxPQ=", 24 | "email": "test_user_1@example.com", 25 | "date_joined": "2013-03-29T07:18:23.300Z" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /coconuts/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class PhotoForm(forms.Form): 5 | SIZE_CHOICES = ( 6 | (128, "128"), 7 | (256, "256"), 8 | (800, "800"), 9 | (1024, "1024"), 10 | (1280, "1280"), 11 | (1600, "1600"), 12 | (2048, "2048"), 13 | (2560, "2560"), 14 | ) 15 | 16 | size = forms.TypedChoiceField(choices=SIZE_CHOICES, coerce=int) 17 | -------------------------------------------------------------------------------- /coconuts/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlaine/django-coconuts/67ef63ebc8a32b2b2343e5cfa37359a50b7ae826/coconuts/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /coconuts/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2013-02-26 09:49+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: forms.py:61 21 | msgid "Who?" 22 | msgstr "Qui ?" 23 | 24 | #: models.py:86 25 | msgid "Can read" 26 | msgstr "Peut lire" 27 | 28 | #: models.py:87 29 | msgid "Can write" 30 | msgstr "Peut écrire" 31 | 32 | #: models.py:88 33 | msgid "Can manage" 34 | msgstr "Peut gérer" 35 | 36 | #: models.py:110 37 | msgid "Description" 38 | msgstr "Description" 39 | 40 | #: notifications.py:32 41 | #, python-format 42 | msgid "New photos from %s" 43 | msgstr "Nouvelles photos de %s" 44 | 45 | #: views.py:98 templates/coconuts/photo_list.html:24 46 | msgid "Add a file" 47 | msgstr "Ajouter un fichier" 48 | 49 | #: views.py:125 templates/coconuts/photo_list.html:25 50 | msgid "Create a folder" 51 | msgstr "Créer un dossier" 52 | 53 | #: templates/coconuts/add_file.html:22 templates/coconuts/add_folder.html:15 54 | #: templates/coconuts/manage.html:32 55 | msgid "Save" 56 | msgstr "Enregistrer" 57 | 58 | #: templates/coconuts/delete.html:5 templates/coconuts/delete.html.py:13 59 | #, python-format 60 | msgid "Really delete \"%(target)s\"?" 61 | msgstr "Réellement supprimer \"%(target)s\" ?" 62 | 63 | #: templates/coconuts/delete.html:20 64 | #, python-format 65 | msgid "" 66 | "Are you sure you want to delete the folder \"%(target)s\" and all its " 67 | "contents?" 68 | msgstr "" 69 | "Etes vous sûr de vouloir supprimer le dossier \"%(target)s\" ainsi que son " 70 | "contenu ?" 71 | 72 | #: templates/coconuts/delete.html:22 73 | #, python-format 74 | msgid "Are you sure you want to delete the file \"%(target)s\"?" 75 | msgstr "Etes-vous sûr de vouloir supprimer le fichier \"%(target)s\" ?" 76 | 77 | #: templates/coconuts/delete.html:29 78 | msgid "Yes, I'm sure" 79 | msgstr "Oui, je suis sûr" 80 | 81 | #: templates/coconuts/delete.html:30 82 | msgid "No, I'm not sure" 83 | msgstr "Non, je ne suis pas sûr" 84 | 85 | #: templates/coconuts/folder_email.html:2 86 | #, python-format 87 | msgid "%(full_name)s has just shared some new photos with you." 88 | msgstr "%(full_name)s vient de partager de nouvelles photos avec vous." 89 | 90 | #: templates/coconuts/folder_email.html:4 91 | msgid "You can see the photos at the following address:" 92 | msgstr "Vous pouvez visualiser les photos à l'adresse suivante:" 93 | 94 | #: templates/coconuts/folder_email.html:8 95 | #, python-format 96 | msgid "The %(site_name)s team" 97 | msgstr "L'équipe %(site_name)s" 98 | 99 | #: templates/coconuts/manage.html:4 templates/coconuts/manage.html.py:13 100 | #, python-format 101 | msgid "Manage %(name)s" 102 | msgstr "Gérer %(name)s" 103 | 104 | #: templates/coconuts/manage.html:18 105 | msgid "Permissions" 106 | msgstr "Permissions" 107 | 108 | #: templates/coconuts/photo_detail.html:24 109 | msgid "Information" 110 | msgstr "Informations" 111 | 112 | #: templates/coconuts/photo_detail.html:26 113 | #: templates/coconuts/photo_detail.html:27 114 | msgid "The size of the original picture" 115 | msgstr "La taille de l'image originale" 116 | 117 | #: templates/coconuts/photo_detail.html:29 118 | msgid "The camera used to take this picture" 119 | msgstr "L'appareil photo utilisé pour prendre cette image" 120 | 121 | #: templates/coconuts/photo_detail.html:32 122 | msgid "The aperture and exposure time for this picture" 123 | msgstr "L'ouverture et le temps d'exposition de cette image" 124 | 125 | #: templates/coconuts/photo_detail.html:36 126 | msgid "Download" 127 | msgstr "Télécharger" 128 | 129 | #: templates/coconuts/photo_detail.html:38 130 | #: templates/coconuts/photo_list.html:49 templates/coconuts/photo_list.html:69 131 | msgid "Delete" 132 | msgstr "Supprimer" 133 | 134 | #: templates/coconuts/photo_list.html:19 135 | msgid "Operations" 136 | msgstr "Opérations" 137 | 138 | #: templates/coconuts/photo_list.html:28 139 | msgid "Manage this share's permissions" 140 | msgstr "Gérer les permissions de ce partage" 141 | 142 | #: templates/coconuts/photo_list.html:31 143 | msgid "Manage user accounts" 144 | msgstr "Gérer les comptes" 145 | 146 | #: templates/coconuts/photo_list.html:42 147 | msgid "There are no files in this folder." 148 | msgstr "Ce dossier ne contient aucun fichier." 149 | 150 | #: templates/coconuts/photo_list.html:49 151 | msgid "Delete this folder" 152 | msgstr "Supprimer ce dossier" 153 | 154 | #: templates/coconuts/photo_list.html:69 155 | msgid "Delete this file" 156 | msgstr "Supprimer ce fichier" 157 | 158 | #: templatetags/coconuts_tags.py:37 159 | msgid "Shares" 160 | msgstr "Partages" 161 | -------------------------------------------------------------------------------- /coconuts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from coconuts import views 4 | 5 | urlpatterns = [ 6 | # backend 7 | re_path(r"^images/contents/(?P.*)$", views.content_list), 8 | re_path(r"^images/download/(?P.*)$", views.download), 9 | re_path(r"^images/render/(?P.*)$", views.render_file), 10 | # frontend 11 | re_path(r"^(?P.*)$", views.browse), 12 | ] 13 | -------------------------------------------------------------------------------- /coconuts/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import mimetypes 3 | import os 4 | import posixpath 5 | import subprocess 6 | import time 7 | import typing 8 | from urllib.parse import quote, unquote 9 | 10 | import django.views.static 11 | from django.conf import settings 12 | from django.contrib.auth.decorators import login_required 13 | from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest 14 | from django.urls import reverse 15 | from django.utils.http import http_date 16 | from PIL import ExifTags, Image 17 | 18 | from coconuts.forms import PhotoForm 19 | 20 | COCONUTS_FRONTEND_ROOT = getattr( 21 | settings, 22 | "COCONUTS_FRONTEND_ROOT", 23 | os.path.join( 24 | os.path.dirname(__file__), "..", "frontend", "dist", "frontend", "browser" 25 | ), 26 | ) 27 | 28 | ORIENTATIONS = { 29 | 1: [False, False, 0], # Horizontal (normal) 30 | 2: [True, False, 0], # Mirrored horizontal 31 | 3: [False, False, 180], # Rotated 180 32 | 4: [False, True, 0], # Mirrored vertical 33 | 5: [True, False, 90], # Mirrored horizontal then rotated 90 CCW 34 | 6: [False, False, -90], # Rotated 90 CW 35 | 7: [True, False, -90], # Mirrored horizontal then rotated 90 CW 36 | 8: [False, False, 90], # Rotated 90 CCW 37 | } 38 | 39 | IMAGE_TYPES = ["image/jpeg", "image/pjpeg", "image/png"] 40 | VIDEO_TYPES = ["video/mp4"] 41 | 42 | 43 | def auth_required(function): 44 | """ 45 | Decorator to check the agent is authenticated. 46 | 47 | Unlike "login_required", if the agent is not authenticated it fails 48 | with a 401 error instead of redirecting. 49 | """ 50 | 51 | def wrap(request, *args, **kwargs): 52 | if request.user.is_authenticated: 53 | return function(request, *args, **kwargs) 54 | 55 | resp = HttpResponse() 56 | resp.status_code = 401 57 | return resp 58 | 59 | return wrap 60 | 61 | 62 | def clean_path(path: str) -> str: 63 | """ 64 | Returns the canonical version of a path 65 | or raises ValueError if the path is invalid. 66 | 67 | Adapted from django.views.static.serve 68 | """ 69 | path = posixpath.normpath(unquote(path)) 70 | path = path.lstrip("/") 71 | newpath = "" 72 | for part in path.split("/"): 73 | if not part: 74 | # Strip empty path components. 75 | continue 76 | drive, part = os.path.splitdrive(part) 77 | head, part = os.path.split(part) 78 | if part in (os.curdir, os.pardir): 79 | # Strip '.' and '..' in path. 80 | continue 81 | newpath = os.path.join(newpath, part).replace("\\", "/") 82 | if newpath and newpath != path: 83 | raise ValueError 84 | return newpath 85 | 86 | 87 | def get_image_exif(image) -> typing.Dict: 88 | """ 89 | Gets an image's EXIF tags as a dict. 90 | """ 91 | metadata = dict(image.getexif()) 92 | metadata.update(image.getexif().get_ifd(ExifTags.IFD.Exif)) 93 | return metadata 94 | 95 | 96 | def format_rational(x) -> str: 97 | if x < 1: 98 | return "1/%d" % round(1 / x) 99 | elif int(x) == x: 100 | return str(int(x)) 101 | else: 102 | return "%.1f" % x 103 | 104 | 105 | def get_image_info(filepath: str) -> typing.Dict: 106 | """ 107 | Gets an image's information. 108 | """ 109 | with Image.open(filepath) as img: 110 | info = { 111 | "width": img.size[0], 112 | "height": img.size[1], 113 | } 114 | metadata = get_image_exif(img) 115 | 116 | # camera 117 | bits = [] 118 | if ExifTags.Base.Model in metadata: 119 | bits.append(metadata[ExifTags.Base.Model].strip()) 120 | if ExifTags.Base.Make in metadata: 121 | make = metadata[ExifTags.Base.Make].strip() 122 | # Do not include the make if it's already in the model. 123 | if not bits or not bits[0].startswith(make): 124 | bits.insert(0, make) 125 | if bits: 126 | info["camera"] = " ".join(bits) 127 | 128 | # settings 129 | bits = [] 130 | if ExifTags.Base.FNumber in metadata: 131 | bits.append("f/%s" % format_rational(metadata[ExifTags.Base.FNumber])) 132 | if ExifTags.Base.ExposureTime in metadata: 133 | bits.append("%s sec" % format_rational(metadata[ExifTags.Base.ExposureTime])) 134 | if ExifTags.Base.FocalLength in metadata: 135 | bits.append("%s mm" % format_rational(metadata[ExifTags.Base.FocalLength])) 136 | if bits: 137 | info["settings"] = ", ".join(bits) 138 | 139 | return info 140 | 141 | 142 | def get_video_info(filepath: str) -> typing.Dict: 143 | """ 144 | Gets a video's information. 145 | """ 146 | data = json.loads( 147 | subprocess.check_output( 148 | [ 149 | "ffprobe", 150 | "-of", 151 | "json", 152 | "-loglevel", 153 | "quiet", 154 | "-show_streams", 155 | "-show_format", 156 | filepath, 157 | ] 158 | ).decode("utf8") 159 | ) 160 | for stream in data["streams"]: 161 | if stream["codec_type"] == "video": 162 | info = { 163 | "duration": float(stream["duration"]), 164 | } 165 | # FFmpeg 4.x. 166 | rotated = stream["tags"].get("rotate") in ["90", "270"] 167 | # Recent FFmpeg. 168 | for side_data in stream.get("side_data_list", []): 169 | if side_data.get("rotation") in [90, 270]: 170 | rotated = True 171 | if rotated: 172 | info["height"] = stream["width"] 173 | info["width"] = stream["height"] 174 | else: 175 | info["height"] = stream["height"] 176 | info["width"] = stream["width"] 177 | return info 178 | 179 | 180 | def serve_static( 181 | request: HttpRequest, 182 | path: str, 183 | *, 184 | document_root: str, 185 | accel_root: typing.Optional[str] = None, 186 | ) -> HttpResponse: 187 | """ 188 | Serve a static document. 189 | 190 | If `accel_root` is set, the file will be served by the reverse proxy, 191 | otherwise Django's static files code is used. 192 | """ 193 | if accel_root: 194 | response = HttpResponse() 195 | response["X-Accel-Redirect"] = posixpath.join(accel_root, path) 196 | else: 197 | response = django.views.static.serve(request, path, document_root=document_root) 198 | return response 199 | 200 | 201 | def url2path(url: str) -> str: 202 | return url.replace("/", os.path.sep) 203 | 204 | 205 | @login_required 206 | def browse(request: HttpRequest, path: str) -> HttpResponse: 207 | """ 208 | Serves the static homepage. 209 | """ 210 | if path.endswith(".css") or path.endswith(".js") or path.endswith(".woff2"): 211 | # Serve static assets. 212 | return serve_static( 213 | request, 214 | path, 215 | document_root=COCONUTS_FRONTEND_ROOT, 216 | ) 217 | else: 218 | # Serve index. 219 | base_href = reverse(browse, args=[""]) 220 | with open(os.path.join(COCONUTS_FRONTEND_ROOT, "index.html"), "r") as fp: 221 | html = fp.read() 222 | return HttpResponse( 223 | html.replace('', f'') 224 | ) 225 | 226 | 227 | @auth_required 228 | def content_list(request: HttpRequest, path: str) -> HttpResponse: 229 | """ 230 | Returns the contents of the given folder. 231 | """ 232 | path = clean_path(path) 233 | 234 | # check folder exists 235 | folder_path = os.path.join(settings.COCONUTS_DATA_ROOT, url2path(path)) 236 | if not os.path.isdir(folder_path): 237 | raise Http404 238 | 239 | # list items 240 | folder_url = "/" + path 241 | if not folder_url.endswith("/"): 242 | folder_url += "/" 243 | folders = [] 244 | files = [] 245 | for entry in sorted(os.listdir(folder_path)): 246 | if entry.startswith("."): 247 | continue 248 | node_path = os.path.join(folder_path, entry) 249 | node_url = folder_url + entry 250 | if os.path.isdir(node_path): 251 | folders.append( 252 | { 253 | "mimetype": "inode/directory", 254 | "name": entry, 255 | "path": node_url + "/", 256 | } 257 | ) 258 | else: 259 | data = { 260 | "mimetype": mimetypes.guess_type(node_path)[0], 261 | "name": entry, 262 | "path": node_url, 263 | "size": os.path.getsize(node_path), 264 | } 265 | if data["mimetype"] in IMAGE_TYPES: 266 | data["image"] = get_image_info(node_path) 267 | elif data["mimetype"] in VIDEO_TYPES: 268 | data["video"] = get_video_info(node_path) 269 | files.append(data) 270 | 271 | return HttpResponse( 272 | json.dumps( 273 | { 274 | "files": files, 275 | "folders": folders, 276 | "name": os.path.basename(folder_path), 277 | "path": folder_url, 278 | } 279 | ), 280 | content_type="application/json", 281 | ) 282 | 283 | 284 | @login_required 285 | def download(request: HttpRequest, path: str) -> HttpResponse: 286 | """ 287 | Returns the raw file for the given photo. 288 | """ 289 | path = clean_path(path) 290 | 291 | # check file exists 292 | filepath = os.path.join(settings.COCONUTS_DATA_ROOT, url2path(path)) 293 | if not os.path.exists(filepath): 294 | raise Http404 295 | 296 | response = serve_static( 297 | request, 298 | path, 299 | accel_root=getattr(settings, "COCONUTS_DATA_ACCEL", None), 300 | document_root=settings.COCONUTS_DATA_ROOT, 301 | ) 302 | response["Content-Disposition"] = 'attachment; filename="%s"' % quote( 303 | posixpath.basename(path) 304 | ) 305 | response["Content-Type"] = mimetypes.guess_type(path)[0] 306 | response["Expires"] = http_date(time.time() + 3600 * 24 * 365) 307 | return response 308 | 309 | 310 | @auth_required 311 | def render_file(request: HttpRequest, path: str) -> HttpResponse: 312 | """ 313 | Returns a resized version of the given photo. 314 | """ 315 | path = clean_path(path) 316 | 317 | # check input 318 | form = PhotoForm(request.GET) 319 | if not form.is_valid(): 320 | return HttpResponseBadRequest() 321 | 322 | # check file exists 323 | filepath = os.path.join(settings.COCONUTS_DATA_ROOT, url2path(path)) 324 | if not os.path.exists(filepath): 325 | raise Http404 326 | 327 | def create_cache_dir(cachefile): 328 | cachedir = os.path.dirname(cachefile) 329 | try: 330 | os.makedirs(cachedir) 331 | except FileExistsError: 332 | pass 333 | 334 | mimetype = mimetypes.guess_type(filepath)[0] 335 | ratio = 0.75 336 | size = form.cleaned_data["size"] 337 | cachesize = size, int(size * ratio) 338 | 339 | if mimetype in IMAGE_TYPES: 340 | # check thumbnail 341 | cachefile = os.path.join( 342 | settings.COCONUTS_CACHE_ROOT, str(size), url2path(path) 343 | ) 344 | if not os.path.exists(cachefile): 345 | create_cache_dir(cachefile) 346 | with Image.open(filepath) as img: 347 | # rotate if needed 348 | orientation = get_image_exif(img).get(ExifTags.Base.Orientation) 349 | if orientation: 350 | img = img.rotate( 351 | ORIENTATIONS[orientation][2], Image.Resampling.NEAREST, True 352 | ) 353 | 354 | img.thumbnail(cachesize, Image.Resampling.LANCZOS) 355 | img.save(cachefile, quality=90) 356 | elif mimetype in VIDEO_TYPES: 357 | mimetype = "image/jpeg" 358 | path += ".jpg" 359 | cachefile = os.path.join( 360 | settings.COCONUTS_CACHE_ROOT, str(size), url2path(path) 361 | ) 362 | if not os.path.exists(cachefile): 363 | create_cache_dir(cachefile) 364 | info = get_video_info(filepath) 365 | pic_ratio = float(info["height"]) / float(info["width"]) 366 | if pic_ratio > ratio: 367 | width = int(cachesize[1] / pic_ratio) 368 | height = cachesize[1] 369 | else: 370 | width = cachesize[0] 371 | height = int(cachesize[0] * pic_ratio) 372 | subprocess.check_call( 373 | [ 374 | "ffmpeg", 375 | "-loglevel", 376 | "quiet", 377 | "-i", 378 | filepath, 379 | "-s", 380 | "%sx%s" % (width, height), 381 | "-vframes", 382 | "1", 383 | cachefile, 384 | ] 385 | ) 386 | else: 387 | # unhandled file type 388 | return HttpResponseBadRequest() 389 | 390 | # serve the photo 391 | response = serve_static( 392 | request, 393 | posixpath.join(str(size), path), 394 | accel_root=getattr(settings, "COCONUTS_CACHE_ACCEL", None), 395 | document_root=settings.COCONUTS_CACHE_ROOT, 396 | ) 397 | response["Content-Type"] = mimetype 398 | response["Expires"] = http_date(time.time() + 3600 * 24 * 365) 399 | return response 400 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /frontend/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /frontend/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Frontend 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.0. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. 28 | -------------------------------------------------------------------------------- /frontend/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "frontend": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular/build:application", 19 | "options": { 20 | "outputPath": "dist/frontend", 21 | "index": "src/index.html", 22 | "browser": "src/main.ts", 23 | "polyfills": [ 24 | "zone.js" 25 | ], 26 | "tsConfig": "tsconfig.app.json", 27 | "inlineStyleLanguage": "scss", 28 | "styles": [ 29 | "src/styles.scss" 30 | ], 31 | "scripts": [] 32 | }, 33 | "configurations": { 34 | "production": { 35 | "budgets": [ 36 | { 37 | "type": "initial", 38 | "maximumWarning": "500kB", 39 | "maximumError": "1MB" 40 | }, 41 | { 42 | "type": "anyComponentStyle", 43 | "maximumWarning": "2kB", 44 | "maximumError": "4kB" 45 | } 46 | ], 47 | "outputHashing": "all" 48 | }, 49 | "development": { 50 | "optimization": false, 51 | "extractLicenses": false, 52 | "sourceMap": true 53 | } 54 | }, 55 | "defaultConfiguration": "production" 56 | }, 57 | "serve": { 58 | "builder": "@angular/build:dev-server", 59 | "configurations": { 60 | "production": { 61 | "buildTarget": "frontend:build:production" 62 | }, 63 | "development": { 64 | "buildTarget": "frontend:build:development" 65 | } 66 | }, 67 | "defaultConfiguration": "development" 68 | }, 69 | "extract-i18n": { 70 | "builder": "@angular/build:extract-i18n" 71 | }, 72 | "test": { 73 | "builder": "@angular/build:karma", 74 | "options": { 75 | "polyfills": [ 76 | "zone.js", 77 | "zone.js/testing" 78 | ], 79 | "tsConfig": "tsconfig.spec.json", 80 | "inlineStyleLanguage": "scss", 81 | "styles": [ 82 | "src/styles.scss" 83 | ], 84 | "scripts": [], 85 | "karmaConfig": "karma.conf.js" 86 | } 87 | }, 88 | "lint": { 89 | "builder": "@angular-eslint/builder:lint", 90 | "options": { 91 | "lintFilePatterns": [ 92 | "src/**/*.ts", 93 | "src/**/*.html" 94 | ] 95 | } 96 | } 97 | } 98 | } 99 | }, 100 | "cli": { 101 | "schematicCollections": [ 102 | "@angular-eslint/schematics" 103 | ], 104 | "analytics": false 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const eslint = require("@eslint/js"); 3 | const tseslint = require("typescript-eslint"); 4 | const angular = require("angular-eslint"); 5 | 6 | module.exports = tseslint.config( 7 | { 8 | files: ["**/*.ts"], 9 | extends: [ 10 | eslint.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | ...tseslint.configs.stylistic, 13 | ...angular.configs.tsRecommended, 14 | ], 15 | processor: angular.processInlineTemplates, 16 | rules: { 17 | "@angular-eslint/directive-selector": [ 18 | "error", 19 | { 20 | type: "attribute", 21 | prefix: "app", 22 | style: "camelCase", 23 | }, 24 | ], 25 | "@angular-eslint/component-selector": [ 26 | "error", 27 | { 28 | type: "element", 29 | prefix: "app", 30 | style: "kebab-case", 31 | }, 32 | ], 33 | "indent": [ 34 | "error", 35 | 4, 36 | { 37 | "SwitchCase": 1 38 | } 39 | ], 40 | }, 41 | }, 42 | { 43 | files: ["**/*.html"], 44 | extends: [ 45 | ...angular.configs.templateRecommended, 46 | ...angular.configs.templateAccessibility, 47 | ], 48 | rules: {}, 49 | } 50 | ); 51 | -------------------------------------------------------------------------------- /frontend/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | ], 14 | client: { 15 | jasmine: { 16 | // you can add configuration options for Jasmine here 17 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 18 | // for example, you can disable the random execution with `random: false` 19 | // or set a specific seed with `seed: 4321` 20 | }, 21 | clearContext: false // leave Jasmine Spec Runner output visible in browser 22 | }, 23 | jasmineHtmlReporter: { 24 | suppressAll: true // removes the duplicated traces 25 | }, 26 | coverageReporter: { 27 | dir: require('path').join(__dirname, './coverage/frontend'), 28 | subdir: '.', 29 | reporters: [ 30 | { type: 'html' }, 31 | { type: 'lcov' }, 32 | { type: 'text-summary' } 33 | ] 34 | }, 35 | reporters: ['progress', 'kjhtml'], 36 | browsers: ['Chrome'], 37 | restartOnFileChange: true 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test", 10 | "lint": "ng lint" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "^20.0.2", 15 | "@angular/common": "^20.0.2", 16 | "@angular/compiler": "^20.0.2", 17 | "@angular/core": "^20.0.2", 18 | "@angular/forms": "^20.0.2", 19 | "@angular/platform-browser": "^20.0.2", 20 | "@angular/platform-browser-dynamic": "^20.0.2", 21 | "material-symbols": "^0.22.1", 22 | "rxjs": "~7.8.0", 23 | "tslib": "^2.3.0", 24 | "zone.js": "~0.15.0" 25 | }, 26 | "devDependencies": { 27 | "@angular/build": "^20.0.1", 28 | "@angular/cli": "^20.0.1", 29 | "@angular/compiler-cli": "^20.0.2", 30 | "@types/jasmine": "~5.1.0", 31 | "angular-eslint": "19.4.0", 32 | "eslint": "^9.9.0", 33 | "jasmine-core": "~5.2.0", 34 | "karma": "~6.4.0", 35 | "karma-chrome-launcher": "~3.2.0", 36 | "karma-coverage": "~2.2.0", 37 | "karma-jasmine": "~5.1.0", 38 | "karma-jasmine-html-reporter": "~2.1.0", 39 | "typescript": "~5.8.3", 40 | "typescript-eslint": "8.1.0" 41 | } 42 | } -------------------------------------------------------------------------------- /frontend/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 |
7 |

{{ currentFolder.name || "Home" }}

8 | 9 |

10 | There are no files in this folder. 11 |

12 | 13 | 22 | 23 | 24 |
25 | 31 |
{{ file.size | fileSize }}
32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 |
40 |
41 |
42 | 43 |
44 |
45 |
46 | 47 | ' 50 |
51 | 52 | 53 |
54 | chevron_left 55 |
56 |
57 | chevron_right 58 |
59 |
60 | 61 |
62 |
close
63 |
64 | download 65 |
info
66 |
fullscreen
67 |
68 | 69 |
70 |
71 | aspect_ratio 72 | {{ mediaCurrent.image.width }} x {{ mediaCurrent.image.height }} pixels - {{ mediaCurrent.size | fileSize }} 73 |
74 |
75 | photo_camera 76 | {{ mediaCurrent.image.camera }} 77 |
78 |
80 | settings 81 | {{ mediaCurrent.image.settings }} 82 |
83 |
84 |
-------------------------------------------------------------------------------- /frontend/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | .text-with-icon { 2 | >* { 3 | vertical-align: middle; 4 | } 5 | } 6 | 7 | .folder-view { 8 | nav { 9 | align-items: center; 10 | background-color: rgb(245, 245, 245); 11 | border-bottom: 1px solid #ccc; 12 | display: flex; 13 | padding: 4px 8px; 14 | 15 | .crumb { 16 | color: #428bca; 17 | text-wrap: nowrap; 18 | 19 | &::after { 20 | content: " \203a"; 21 | margin-right: 8px; 22 | } 23 | 24 | &:last-child::after { 25 | content: '' 26 | } 27 | } 28 | } 29 | 30 | main { 31 | padding-left: 20px; 32 | padding-right: 20px; 33 | 34 | h1 { 35 | font-size: 24px; 36 | line-height: 26px; 37 | } 38 | 39 | .file { 40 | display: table-row; 41 | 42 | img { 43 | border: 0px; 44 | padding: 2px; 45 | margin-right: 5px; 46 | vertical-align: middle; 47 | } 48 | 49 | .filename { 50 | display: table-cell; 51 | padding-right: 10px; 52 | min-width: 10em; 53 | } 54 | 55 | .filesize { 56 | display: table-cell; 57 | padding: 0 5px; 58 | } 59 | } 60 | 61 | .thumbnails { 62 | .file { 63 | border: 1px solid #ddd; 64 | border-radius: 4px; 65 | cursor: pointer; 66 | display: inline-block; 67 | margin-bottom: 20px; 68 | margin-right: 20px; 69 | padding: 0; 70 | width: 138px; 71 | height: 106px; 72 | vertical-align: middle; 73 | 74 | img { 75 | max-width: 128px; 76 | max-height: 96px; 77 | position: relative; 78 | margin: 0; 79 | left: 50%; 80 | top: 50%; 81 | transform: translateX(-50%) translateY(-50%); 82 | } 83 | } 84 | } 85 | } 86 | } 87 | 88 | .photo-view { 89 | background-color: black; 90 | 91 | .photo-display { 92 | height: 100vh; 93 | position: relative; 94 | 95 | .photo-image { 96 | height: 100%; 97 | width: 100%; 98 | 99 | >img, 100 | >video { 101 | display: block; 102 | max-height: 100%; 103 | max-width: 100%; 104 | position: relative; 105 | left: 50%; 106 | top: 50%; 107 | transform: translateX(-50%) translateY(-50%); 108 | user-select: none; 109 | } 110 | } 111 | 112 | .photo-control { 113 | cursor: pointer; 114 | position: absolute; 115 | height: 100%; 116 | width: 48px; 117 | margin: 0; 118 | top: 0px; 119 | user-select: none; 120 | 121 | >span { 122 | font-size: 48px; 123 | font-weight: bold; 124 | color: white; 125 | margin-top: -30px; 126 | position: absolute; 127 | top: 50%; 128 | } 129 | 130 | &.right { 131 | right: 0; 132 | } 133 | 134 | &:hover { 135 | opacity: 0.8; 136 | text-decoration: none; 137 | } 138 | 139 | &:focus { 140 | outline: 0; 141 | } 142 | } 143 | } 144 | 145 | .photo-nav { 146 | display: flex; 147 | position: absolute; 148 | left: 0; 149 | right: 0; 150 | top: 0; 151 | 152 | .button { 153 | color: white; 154 | cursor: pointer; 155 | padding: 12px; 156 | } 157 | 158 | .spacer { 159 | flex: 1; 160 | } 161 | } 162 | 163 | .photo-info { 164 | align-items: start; 165 | background-color: rgb(245, 245, 245); 166 | display: flex; 167 | flex-direction: column; 168 | padding: 16px; 169 | position: absolute; 170 | bottom: 0; 171 | left: 0; 172 | right: 0; 173 | } 174 | } -------------------------------------------------------------------------------- /frontend/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { DOCUMENT } from '@angular/core'; 3 | import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; 4 | import { provideHttpClient } from '@angular/common/http'; 5 | 6 | import { AppComponent, getImageSize } from './app.component'; 7 | import { FolderContents } from './file.service'; 8 | import { ReplaySubject } from 'rxjs'; 9 | import { RouterService } from './router.service'; 10 | 11 | 12 | const HOME_CONTENTS: FolderContents = { 13 | files: [], 14 | folders: [{ 15 | mimetype: 'inode/directory', 16 | name: '2024', 17 | path: '/2024/', 18 | }], 19 | name: '' 20 | }; 21 | 22 | const FOLDER_CONTENTS: FolderContents = { 23 | files: [ 24 | { 25 | "mimetype": "image/jpeg", 26 | "name": "IMG_5032.JPG", 27 | "path": "/2024/IMG_5032.JPG", 28 | "size": 4275032, 29 | "image": { 30 | "width": 5123, 31 | "height": 3415, 32 | "camera": "Canon EOS 5D Mark II", 33 | "settings": "f/9, 1/400 sec, 84 mm" 34 | } 35 | }, 36 | { 37 | 38 | "mimetype": "video/mp4", 39 | "name": "IMG_5209.MP4", 40 | "path": "/2024/IMG_5209.MP4", 41 | "size": 16695465, 42 | "video": { 43 | "duration": 29.72, 44 | "height": 1080, 45 | "width": 1920 46 | 47 | } 48 | } 49 | ], 50 | folders: [], 51 | name: '2024' 52 | }; 53 | 54 | export class RouterMock { 55 | path$ = new ReplaySubject(); 56 | pathPrefix = ''; 57 | 58 | constructor() { 59 | this.path$.next('/'); 60 | } 61 | 62 | go(path: string) { 63 | this.path$.next(path); 64 | } 65 | } 66 | 67 | describe('AppComponent', () => { 68 | let component: AppComponent; 69 | let document: Document; 70 | let fixture: ComponentFixture; 71 | let httpMock: HttpTestingController; 72 | let router: RouterService; 73 | 74 | beforeEach(async () => { 75 | await TestBed.configureTestingModule({ 76 | imports: [AppComponent], 77 | providers: [ 78 | provideHttpClient(), 79 | provideHttpClientTesting(), 80 | { 81 | provide: RouterService, 82 | useClass: RouterMock, 83 | } 84 | ] 85 | }).compileComponents(); 86 | 87 | document = TestBed.inject(DOCUMENT); 88 | fixture = TestBed.createComponent(AppComponent); 89 | httpMock = TestBed.inject(HttpTestingController); 90 | router = TestBed.inject(RouterService); 91 | 92 | component = fixture.componentInstance; 93 | }); 94 | 95 | afterEach(() => { 96 | httpMock.verify(); 97 | }) 98 | 99 | 100 | describe('start from home', () => { 101 | beforeEach(() => { 102 | fixture.detectChanges(); 103 | 104 | httpMock.expectOne({ 105 | method: 'GET', 106 | url: '/images/contents/', 107 | }).flush(HOME_CONTENTS) 108 | fixture.detectChanges(); 109 | }); 110 | 111 | it('should display home', () => { 112 | expect(component.showInformation).toBeFalse(); 113 | expect(component.showThumbnails).toBeTrue(); 114 | 115 | // Keypress does nothing. 116 | component.handleKeypress(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); 117 | component.handleKeypress(new KeyboardEvent('keydown', { key: 'ArrowRight' })); 118 | }); 119 | 120 | it('should display folder', () => { 121 | // Navigate to folder. 122 | router.go('/2024/'); 123 | httpMock.expectOne({ 124 | method: 'GET', 125 | url: '/images/contents/2024/', 126 | }).flush(FOLDER_CONTENTS) 127 | fixture.detectChanges(); 128 | 129 | // Open media. 130 | component.mediaOpen(FOLDER_CONTENTS.files[0]); 131 | fixture.detectChanges(); 132 | expect(component.mediaCurrent).toBe(FOLDER_CONTENTS.files[0]); 133 | expect(component.mediaNext).toBe(FOLDER_CONTENTS.files[1]); 134 | expect(component.mediaPrevious).toBeNull(); 135 | 136 | // Show previous -> noop. 137 | component.showPrevious(); 138 | fixture.detectChanges(); 139 | expect(component.mediaCurrent).toBe(FOLDER_CONTENTS.files[0]); 140 | expect(component.mediaNext).toBe(FOLDER_CONTENTS.files[1]); 141 | expect(component.mediaPrevious).toBeNull(); 142 | 143 | // Show next. 144 | component.showNext(); 145 | fixture.detectChanges(); 146 | expect(component.mediaCurrent).toBe(FOLDER_CONTENTS.files[1]); 147 | expect(component.mediaNext).toBe(null); 148 | expect(component.mediaPrevious).toBe(FOLDER_CONTENTS.files[0]); 149 | 150 | // Show next -> noop. 151 | component.showNext(); 152 | fixture.detectChanges(); 153 | expect(component.mediaCurrent).toBe(FOLDER_CONTENTS.files[1]); 154 | expect(component.mediaNext).toBe(null); 155 | expect(component.mediaPrevious).toBe(FOLDER_CONTENTS.files[0]); 156 | 157 | // Toggle information. 158 | component.toggleInformation(); 159 | fixture.detectChanges(); 160 | expect(component.showInformation).toBeTrue(); 161 | component.toggleInformation(); 162 | fixture.detectChanges(); 163 | expect(component.showInformation).toBeFalse(); 164 | 165 | // Show previous. 166 | component.showPrevious(); 167 | fixture.detectChanges(); 168 | expect(component.mediaCurrent).toBe(FOLDER_CONTENTS.files[0]); 169 | expect(component.mediaNext).toBe(FOLDER_CONTENTS.files[1]); 170 | expect(component.mediaPrevious).toBeNull(); 171 | 172 | // Close media. 173 | component.mediaClose(); 174 | }); 175 | 176 | it('should toggle fullscreen', () => { 177 | let fullscreenElement: HTMLElement | null = null; 178 | 179 | spyOnProperty(document, 'fullscreenElement', 'get').and.callFake(() => fullscreenElement); 180 | spyOn(document, 'exitFullscreen').and.callFake(() => { 181 | fullscreenElement = null; 182 | return Promise.resolve(void 0); 183 | }); 184 | spyOn(document.documentElement, 'requestFullscreen').and.callFake(() => { 185 | fullscreenElement = document.documentElement; 186 | return Promise.resolve(void 0); 187 | }); 188 | 189 | // Enter fullscreen. 190 | component.toggleFullscreen(); 191 | expect(fullscreenElement).not.toBeNull(); 192 | 193 | // Exit fullscreen. 194 | component.toggleFullscreen(); 195 | expect(fullscreenElement).toBeNull(); 196 | }); 197 | }); 198 | 199 | it('should return image size', () => { 200 | const testImageSize = (clientHeight: number, clientWidth: number, devicePixelRatio: number) => getImageSize( 201 | { clientHeight: clientHeight, clientWidth: clientWidth } as HTMLElement, 202 | { devicePixelRatio: devicePixelRatio } as Window, 203 | ); 204 | expect(testImageSize(640, 480, 1)).toBe(800); 205 | expect(testImageSize(480, 640, 1)).toBe(800); 206 | 207 | expect(testImageSize(640, 480, 2)).toBe(1280); 208 | expect(testImageSize(480, 640, 2)).toBe(1280); 209 | 210 | expect(testImageSize(1600, 1200, 2)).toBe(2560); 211 | expect(testImageSize(1200, 1600, 2)).toBe(2560); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component, DOCUMENT, HostListener, Inject, OnInit } from '@angular/core'; 3 | import { map, Observable, switchMap, tap } from 'rxjs'; 4 | 5 | import { FileService, FolderContents, FolderFile } from './file.service'; 6 | import { FileIconPipe } from './file-icon.pipe'; 7 | import { FileSizePipe } from './file-size.pipe'; 8 | import { RouterLinkDirective } from './router-link.directive'; 9 | import { RouterService } from './router.service'; 10 | 11 | interface Crumb { 12 | name: string; 13 | path: string; 14 | } 15 | 16 | export const getImageSize = (documentElement: HTMLElement, window: Window) => { 17 | const sizes = [800, 1024, 1280, 1600, 2048, 2560]; 18 | const screenSize = Math.max( 19 | documentElement.clientWidth, 20 | documentElement.clientHeight 21 | ) * window.devicePixelRatio; 22 | for (var i = 0; i < sizes.length; i++) { 23 | if (screenSize <= sizes[i]) { 24 | return sizes[i]; 25 | } 26 | } 27 | return sizes[sizes.length - 1]; 28 | } 29 | 30 | @Component({ 31 | selector: 'app-root', 32 | imports: [ 33 | CommonModule, 34 | FileSizePipe, 35 | FileIconPipe, 36 | RouterLinkDirective, 37 | ], 38 | templateUrl: './app.component.html', 39 | styleUrl: './app.component.scss' 40 | }) 41 | export class AppComponent implements OnInit { 42 | crumbs$: Observable; 43 | currentFolder: FolderContents | null = null; 44 | showInformation = false; 45 | showThumbnails = false; 46 | 47 | mediaCurrent: FolderFile | null = null; 48 | mediaNext: FolderFile | null = null; 49 | mediaPrevious: FolderFile | null = null; 50 | 51 | private currentFolder$: Observable; 52 | private mediaFiles: FolderFile[] = []; 53 | private mediaSize = 800; 54 | 55 | @HostListener('document:keydown', ['$event']) 56 | handleKeypress(event: KeyboardEvent) { 57 | switch (event.key) { 58 | case 'ArrowLeft': 59 | this.showPrevious(); 60 | break; 61 | case 'ArrowRight': 62 | this.showNext(); 63 | break; 64 | } 65 | } 66 | 67 | @HostListener('window:resize') 68 | handleResize() { 69 | this.mediaSize = getImageSize(document.documentElement, window); 70 | } 71 | 72 | constructor( 73 | @Inject(DOCUMENT) private document: Document, 74 | private fileService: FileService, 75 | router: RouterService, 76 | ) { 77 | this.crumbs$ = router.path$.pipe( 78 | map((path) => { 79 | let crumbPath = ''; 80 | return path.split('/').slice(0, -1).map((bit) => { 81 | crumbPath += bit + '/'; 82 | return { name: crumbPath === '/' ? 'Home' : bit, path: crumbPath }; 83 | }); 84 | }) 85 | ); 86 | 87 | this.currentFolder$ = router.path$.pipe( 88 | switchMap((path) => this.fileService.folderContents(path)) 89 | ); 90 | } 91 | 92 | fileDownload(file: FolderFile) { 93 | return this.fileService.fileDownload(file); 94 | } 95 | 96 | fileRender(file: FolderFile) { 97 | return this.fileService.fileRender(file, this.mediaSize); 98 | } 99 | 100 | fileThumbnail(file: FolderFile) { 101 | return this.fileService.fileRender(file, 256); 102 | } 103 | 104 | ngOnInit() { 105 | this.handleResize(); 106 | 107 | this.currentFolder$.subscribe((contents) => { 108 | this.mediaFiles = contents.files.filter((x) => x.image !== undefined || x.video !== undefined); 109 | this.currentFolder = contents; 110 | this.mediaCurrent = null; 111 | this.mediaNext = null; 112 | this.mediaPrevious = null; 113 | 114 | // Show thumbnails if the current directory contains only media. 115 | this.showThumbnails = this.mediaFiles.length === contents.files.length; 116 | }); 117 | } 118 | 119 | mediaClose() { 120 | this.mediaCurrent = null; 121 | this.mediaNext = null; 122 | this.mediaPrevious = null; 123 | } 124 | 125 | mediaOpen(file: FolderFile) { 126 | const idx = this.mediaFiles.indexOf(file); 127 | this.mediaCurrent = file; 128 | this.mediaPrevious = this.mediaFiles[idx - 1] || null; 129 | this.mediaNext = this.mediaFiles[idx + 1] || null; 130 | } 131 | 132 | showNext() { 133 | if (this.mediaNext) { 134 | this.mediaOpen(this.mediaNext); 135 | } 136 | } 137 | 138 | showPrevious() { 139 | if (this.mediaPrevious) { 140 | this.mediaOpen(this.mediaPrevious); 141 | } 142 | } 143 | 144 | toggleInformation() { 145 | this.showInformation = !this.showInformation; 146 | } 147 | 148 | toggleFullscreen() { 149 | if (!this.document.fullscreenElement) { 150 | this.document.documentElement.requestFullscreen(); 151 | } else { 152 | this.document.exitFullscreen(); 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /frontend/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; 2 | import { provideHttpClient } from '@angular/common/http'; 3 | 4 | export const appConfig: ApplicationConfig = { 5 | providers: [ 6 | provideZoneChangeDetection({ eventCoalescing: true }), 7 | provideHttpClient(), 8 | ] 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/app/file-icon.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { FileIconPipe } from './file-icon.pipe'; 2 | 3 | describe('FileIconPipe', () => { 4 | let pipe: FileIconPipe; 5 | 6 | beforeEach(() => { 7 | pipe = new FileIconPipe(); 8 | }); 9 | 10 | it('should return directory icon', () => { 11 | expect(pipe.transform('inode/directory')).toBe('folder'); 12 | }); 13 | 14 | it('should return image icon', () => { 15 | expect(pipe.transform('image/jpeg')).toBe('image'); 16 | expect(pipe.transform('image/png')).toBe('image'); 17 | }); 18 | 19 | it('should return text icon', () => { 20 | expect(pipe.transform('text/plain')).toBe('description'); 21 | expect(pipe.transform('text/html')).toBe('description'); 22 | }); 23 | 24 | it('should return unknown icon', () => { 25 | expect(pipe.transform('application/octet-stream')).toBe('draft'); 26 | }); 27 | 28 | it('should return video icon', () => { 29 | expect(pipe.transform('video/mp4')).toBe('video_file'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /frontend/src/app/file-icon.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'fileIcon', 5 | standalone: true 6 | }) 7 | export class FileIconPipe implements PipeTransform { 8 | transform(mimetype: string): unknown { 9 | if (mimetype === 'inode/directory') { 10 | return 'folder'; 11 | } else if (mimetype.indexOf('image/') === 0) { 12 | return 'image'; 13 | } else if (mimetype.indexOf('text/') === 0) { 14 | return 'description'; 15 | } else if (mimetype.indexOf('video/') === 0) { 16 | return 'video_file'; 17 | } else { 18 | return 'draft'; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/app/file-size.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { FileSizePipe } from './file-size.pipe'; 2 | 3 | describe('FileSizePipe', () => { 4 | let pipe: FileSizePipe; 5 | 6 | beforeEach(() => { 7 | pipe = new FileSizePipe(); 8 | }); 9 | 10 | it('should format size in bytes', () => { 11 | expect(pipe.transform(0)).toBe('0 B'); 12 | expect(pipe.transform(1023)).toBe('1023 B'); 13 | }); 14 | 15 | it('should format size in kibibytes', () => { 16 | expect(pipe.transform(1024)).toBe('1.0 kiB'); 17 | expect(pipe.transform(1048575)).toBe('1024.0 kiB'); 18 | }); 19 | 20 | it('should format size in mebibytes', () => { 21 | expect(pipe.transform(1048576)).toBe('1.0 MiB'); 22 | expect(pipe.transform(1073741823)).toBe('1024.0 MiB'); 23 | }); 24 | 25 | it('should format size in gibibytes', () => { 26 | expect(pipe.transform(1073741824)).toBe('1.0 GiB'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /frontend/src/app/file-size.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | const GiB = 1024 * 1024 * 1024; 4 | const MiB = 1024 * 1024; 5 | const KiB = 1024; 6 | 7 | @Pipe({ 8 | name: 'fileSize', 9 | standalone: true 10 | }) 11 | export class FileSizePipe implements PipeTransform { 12 | transform(value: number): unknown { 13 | if (value >= GiB) { 14 | return (value / GiB).toFixed(1) + ' GiB'; 15 | } else if (value >= MiB) { 16 | return (value / MiB).toFixed(1) + ' MiB'; 17 | } else if (value >= KiB) { 18 | return (value / KiB).toFixed(1) + ' kiB'; 19 | } else { 20 | return value + ' B'; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/app/file.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideHttpClientTesting } from '@angular/common/http/testing'; 3 | import { provideHttpClient } from '@angular/common/http'; 4 | 5 | import { FileService } from './file.service'; 6 | 7 | describe('FileService', () => { 8 | let service: FileService; 9 | 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({ 12 | providers: [ 13 | provideHttpClient(), 14 | provideHttpClientTesting(), 15 | ] 16 | }); 17 | service = TestBed.inject(FileService); 18 | }); 19 | 20 | it('should be created', () => { 21 | expect(service).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /frontend/src/app/file.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | 4 | import { RouterService } from './router.service'; 5 | 6 | 7 | export interface FolderFile { 8 | mimetype: string; 9 | name: string; 10 | path: string; 11 | size: number; 12 | 13 | image?: { 14 | camera: string; 15 | height: number; 16 | settings: string; 17 | width: number; 18 | } 19 | 20 | video?: { 21 | duration: number; 22 | height: number; 23 | width: number; 24 | } 25 | } 26 | 27 | export interface FolderContents { 28 | files: FolderFile[]; 29 | folders: { 30 | mimetype: string; 31 | name: string; 32 | path: string; 33 | }[]; 34 | name: string; 35 | } 36 | 37 | @Injectable({ 38 | providedIn: 'root', 39 | }) 40 | export class FileService { 41 | private apiRoot: string; 42 | 43 | constructor( 44 | private http: HttpClient, 45 | router: RouterService, 46 | ) { 47 | this.apiRoot = router.pathPrefix + '/images' 48 | } 49 | 50 | fileDownload(file: FolderFile) { 51 | return this.apiRoot + '/download' + file.path; 52 | } 53 | 54 | fileRender(file: FolderFile, size: number) { 55 | return this.apiRoot + '/render' + file.path + '?size=' + size; 56 | } 57 | 58 | folderContents(path: string) { 59 | return this.http.get(this.apiRoot + '/contents' + path); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/app/router-link.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { By } from '@angular/platform-browser'; 2 | import { Component } from '@angular/core'; 3 | import { RouterLinkDirective } from './router-link.directive'; 4 | import { TestBed } from '@angular/core/testing'; 5 | 6 | import { RouterMock } from './app.component.spec'; 7 | import { RouterService } from './router.service'; 8 | 9 | @Component({ 10 | imports: [RouterLinkDirective], 11 | template: `2024` 12 | }) 13 | class RouterLinkTestComponent { } 14 | 15 | describe('RouterLinkDirective', () => { 16 | let router: RouterService; 17 | 18 | beforeEach(async () => { 19 | await TestBed.configureTestingModule({ 20 | imports: [RouterLinkTestComponent], 21 | providers: [ 22 | { 23 | provide: RouterService, 24 | useClass: RouterMock, 25 | } 26 | ] 27 | }).compileComponents(); 28 | 29 | router = TestBed.inject(RouterService); 30 | spyOn(router, 'go'); 31 | }); 32 | 33 | it('should work without path prefix', () => { 34 | const fixture = TestBed.createComponent(RouterLinkTestComponent); 35 | const link: HTMLAnchorElement = fixture.debugElement.query(By.css('a')).nativeElement; 36 | expect(link.pathname).toBe('/2024/'); 37 | 38 | link.click(); 39 | expect(router.go).toHaveBeenCalledOnceWith('/2024/'); 40 | }); 41 | 42 | it('should work with path prefix', () => { 43 | router.pathPrefix = '/someprefix'; 44 | 45 | const fixture = TestBed.createComponent(RouterLinkTestComponent); 46 | const link: HTMLAnchorElement = fixture.debugElement.query(By.css('a')).nativeElement; 47 | expect(link.pathname).toBe('/someprefix/2024/'); 48 | 49 | link.click(); 50 | expect(router.go).toHaveBeenCalledOnceWith('/2024/'); 51 | }); 52 | 53 | }); 54 | -------------------------------------------------------------------------------- /frontend/src/app/router-link.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, HostListener, Input, Renderer2 } from '@angular/core'; 2 | 3 | import { RouterService } from './router.service'; 4 | 5 | @Directive({ 6 | selector: '[routerLink]', 7 | standalone: true, 8 | }) 9 | export class RouterLinkDirective { 10 | private path = ''; 11 | 12 | constructor( 13 | private element: ElementRef, 14 | private renderer: Renderer2, 15 | private router: RouterService, 16 | ) { } 17 | 18 | @Input() 19 | set routerLink(path: string) { 20 | this.path = path; 21 | this.renderer.setAttribute(this.element.nativeElement, 'href', this.router.pathPrefix + path); 22 | } 23 | 24 | @HostListener( 25 | 'click', 26 | ['$event.button', '$event.ctrlKey', '$event.shiftKey', '$event.altKey', '$event.metaKey']) 27 | onClick(button: number, ctrlKey: boolean, shiftKey: boolean, altKey: boolean, metaKey: boolean): boolean { 28 | this.router.go(this.path); 29 | return false; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/app/router.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { RouterService, stripBasePath, stripTrailingSlash } from './router.service'; 4 | 5 | describe('RouterService', () => { 6 | let service: RouterService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(RouterService); 11 | }); 12 | 13 | it('should navigate', () => { 14 | const paths: string[] = []; 15 | const spy = spyOn(window.history, 'pushState'); 16 | const sub = service.path$.subscribe((path) => { 17 | paths.push(path); 18 | }); 19 | 20 | service.pathPrefix = '/app/'; 21 | expect(paths).toEqual(['']); 22 | 23 | service.go('some/path/'); 24 | expect(spy).toHaveBeenCalledWith(null, '', '/app/some/path/'); 25 | expect(paths).toEqual(['', 'some/path/']); 26 | 27 | sub.unsubscribe(); 28 | }); 29 | 30 | it('should strip base path', () => { 31 | expect(stripBasePath('/', '/some/path')).toBe('some/path'); 32 | 33 | expect(stripBasePath('/app/', '/app/some/path')).toBe('some/path'); 34 | expect(stripBasePath('/app/', '/some/path')).toBe('/some/path'); 35 | }); 36 | 37 | it('should strip trailing slash', () => { 38 | expect(stripTrailingSlash('foo/bar')).toBe('foo/bar'); 39 | expect(stripTrailingSlash('foo/bar/')).toBe('foo/bar'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /frontend/src/app/router.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable, ReplaySubject } from 'rxjs'; 3 | 4 | export const stripBasePath = (basePath: string, url: string): string => { 5 | if (url.startsWith(basePath)) { 6 | return url.substring(basePath.length); 7 | } else { 8 | return url; 9 | } 10 | } 11 | 12 | export const stripTrailingSlash = (url: string): string => { 13 | if (url.endsWith('/')) { 14 | return url.substring(0, url.length - 1); 15 | } else { 16 | return url; 17 | } 18 | } 19 | 20 | @Injectable({ 21 | providedIn: 'root' 22 | }) 23 | export class RouterService { 24 | path$: Observable; 25 | pathPrefix: string; 26 | 27 | private pathSubject$ = new ReplaySubject(); 28 | 29 | constructor() { 30 | this.pathPrefix = stripTrailingSlash(document.baseURI.split(/\/\/[^\/]+/)[1]); 31 | this.pathSubject$.next(stripBasePath(this.pathPrefix, location.pathname)); 32 | this.path$ = this.pathSubject$.asObservable(); 33 | } 34 | 35 | go(path: string) { 36 | history.pushState(null, '', this.pathPrefix + path); 37 | this.pathSubject$.next(path); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Coconuts 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /frontend/src/styles.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:meta'; 2 | 3 | @include meta.load-css('material-symbols/outlined'); 4 | 5 | body, 6 | html { 7 | margin: 0; 8 | } 9 | 10 | body { 11 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 12 | font-size: 16px; 13 | } 14 | 15 | a { 16 | color: #428bca; 17 | text-decoration: none; 18 | } 19 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/app", 7 | "types": [] 8 | }, 9 | "files": [ 10 | "src/main.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "compileOnSave": false, 5 | "compilerOptions": { 6 | "outDir": "./dist/out-tsc", 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "sourceMap": true, 16 | "declaration": false, 17 | "experimentalDecorators": true, 18 | "moduleResolution": "bundler", 19 | "importHelpers": true, 20 | "target": "ES2022", 21 | "module": "ES2022", 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/spec", 7 | "types": [ 8 | "jasmine" 9 | ] 10 | }, 11 | "include": [ 12 | "src/**/*.spec.ts", 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | if "test" in sys.argv: 8 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 9 | 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "coconuts" 7 | description = "A simple photo sharing web application" 8 | readme = "README.rst" 9 | requires-python = ">=3.9" 10 | license = "BSD-2-Clause" 11 | authors = [ 12 | { name = "Jeremy Lainé", email = "jeremy.laine@m4x.org" }, 13 | ] 14 | version = "0.4.0" 15 | 16 | [tool.coverage.run] 17 | include = ["coconuts/*"] 18 | 19 | [tool.ruff.lint] 20 | select = [ 21 | "E", # pycodestyle 22 | "F", # Pyflakes 23 | "W", # pycodestyle 24 | "I", # isort 25 | "T20", # flake8-print 26 | ] 27 | 28 | [tool.setuptools] 29 | packages = ["coconuts"] 30 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django >= 4.2.21, < 5 2 | pillow >= 11.2.1, < 12 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import os 4 | import shutil 5 | 6 | from django.conf import settings 7 | from django.test import TestCase 8 | from PIL import Image 9 | 10 | 11 | class BaseTest(TestCase): 12 | maxDiff = None 13 | files = [] 14 | folders = [] 15 | 16 | def assertImage( 17 | self, 18 | response, 19 | *, 20 | content_type, 21 | image_size, 22 | content_disposition=None, 23 | ): 24 | """ 25 | Check that a reponse contains an image. 26 | """ 27 | self.assertEqual(response.status_code, 200) 28 | self.assertEqual(response["Content-Type"], content_type) 29 | if content_disposition is not None: 30 | self.assertEqual(response["Content-Disposition"], content_disposition) 31 | self.assertIn("Expires", response) 32 | self.assertIn("Last-Modified", response) 33 | 34 | # check image size 35 | fp = io.BytesIO(b"".join(response.streaming_content)) 36 | with Image.open(fp) as img: 37 | self.assertEqual(img.size, image_size) 38 | 39 | def assertImageAccel(self, response, *, content_type, x_accel_redirect): 40 | """ 41 | Check that a reponse is an acceleration redirect for an image. 42 | """ 43 | self.assertEqual(response.status_code, 200) 44 | self.assertEqual(response["Content-Type"], content_type) 45 | self.assertEqual(response["X-Accel-Redirect"], x_accel_redirect) 46 | self.assertIn("Expires", response) 47 | self.assertNotIn("Last-Modified", response) 48 | 49 | def assertJson(self, response, data, status_code=200): 50 | """ 51 | Checks that a response represents the given data as JSON. 52 | """ 53 | self.assertEqual(response.status_code, status_code) 54 | self.assertEqual(response["Content-Type"], "application/json") 55 | self.assertEqual(json.loads(response.content.decode("utf8")), data) 56 | 57 | def postJson(self, url, data): 58 | """ 59 | Posts data as JSON. 60 | """ 61 | return self.client.post(url, json.dumps(data), content_type="application/json") 62 | 63 | def setUp(self): 64 | """ 65 | Creates temporary directories. 66 | """ 67 | for path in [ 68 | settings.COCONUTS_CACHE_ROOT, 69 | settings.COCONUTS_DATA_ROOT, 70 | settings.COCONUTS_FRONTEND_ROOT, 71 | ]: 72 | os.makedirs(path) 73 | 74 | # Populate data directory. 75 | for name in self.folders: 76 | dest_path = os.path.join(settings.COCONUTS_DATA_ROOT, name) 77 | os.makedirs(dest_path) 78 | for name in self.files: 79 | source_path = os.path.join(os.path.dirname(__file__), "data", name) 80 | dest_path = os.path.join(settings.COCONUTS_DATA_ROOT, name) 81 | shutil.copyfile(source_path, dest_path) 82 | 83 | # Populate frontend directory. 84 | for name in ["index.html", "test.css"]: 85 | dest_path = os.path.join(settings.COCONUTS_FRONTEND_ROOT, name) 86 | with open(dest_path, "w") as fp: 87 | fp.write("test") 88 | 89 | def tearDown(self): 90 | """ 91 | Removes temporary directories. 92 | """ 93 | for path in [ 94 | settings.COCONUTS_CACHE_ROOT, 95 | settings.COCONUTS_DATA_ROOT, 96 | settings.COCONUTS_FRONTEND_ROOT, 97 | ]: 98 | shutil.rmtree(path) 99 | -------------------------------------------------------------------------------- /tests/data/.test.txt: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /tests/data/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlaine/django-coconuts/67ef63ebc8a32b2b2343e5cfa37359a50b7ae826/tests/data/test.jpg -------------------------------------------------------------------------------- /tests/data/test.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlaine/django-coconuts/67ef63ebc8a32b2b2343e5cfa37359a50b7ae826/tests/data/test.mp4 -------------------------------------------------------------------------------- /tests/data/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlaine/django-coconuts/67ef63ebc8a32b2b2343e5cfa37359a50b7ae826/tests/data/test.png -------------------------------------------------------------------------------- /tests/data/test.txt: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /tests/data/test_finepix.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlaine/django-coconuts/67ef63ebc8a32b2b2343e5cfa37359a50b7ae826/tests/data/test_finepix.jpg -------------------------------------------------------------------------------- /tests/data/test_portrait.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlaine/django-coconuts/67ef63ebc8a32b2b2343e5cfa37359a50b7ae826/tests/data/test_portrait.jpg -------------------------------------------------------------------------------- /tests/data/test_portrait.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlaine/django-coconuts/67ef63ebc8a32b2b2343e5cfa37359a50b7ae826/tests/data/test_portrait.mp4 -------------------------------------------------------------------------------- /tests/data/test_rotated.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlaine/django-coconuts/67ef63ebc8a32b2b2343e5cfa37359a50b7ae826/tests/data/test_rotated.jpg -------------------------------------------------------------------------------- /tests/data/test_rotated.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlaine/django-coconuts/67ef63ebc8a32b2b2343e5cfa37359a50b7ae826/tests/data/test_rotated.mp4 -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import os 3 | 4 | DEBUG = True 5 | 6 | ADMINS = ( 7 | # ('Your Name', 'your_email@example.com'), 8 | ) 9 | 10 | DATABASES = { 11 | "default": { 12 | "ENGINE": "django.db.backends.sqlite3", 13 | "NAME": "", # Or path to database file if using sqlite3. 14 | } 15 | } 16 | 17 | # Hosts/domain names that are valid for this site; required if DEBUG is False 18 | # See https://docs.djangoproject.com/en/1.4/ref/settings/#allowed-hosts 19 | ALLOWED_HOSTS = [] 20 | 21 | # Local time zone for this installation. Choices can be found here: 22 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 23 | # although not all choices may be available on all operating systems. 24 | # In a Windows environment this must be set to your system time zone. 25 | TIME_ZONE = "America/Chicago" 26 | 27 | # Language code for this installation. All choices can be found here: 28 | # http://www.i18nguy.com/unicode/language-identifiers.html 29 | LANGUAGE_CODE = "en-us" 30 | 31 | # If you set this to False, Django will make some optimizations so as not 32 | # to load the internationalization machinery. 33 | USE_I18N = True 34 | 35 | # If you set this to False, Django will not use timezone-aware datetimes. 36 | USE_TZ = True 37 | 38 | # Absolute path to the directory static files should be collected to. 39 | # Don't put anything in this directory yourself; store your static files 40 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 41 | # Example: "/home/media/media.lawrence.com/static/" 42 | STATIC_ROOT = "" 43 | 44 | # URL prefix for static files. 45 | # Example: "http://media.lawrence.com/static/" 46 | STATIC_URL = "/static/" 47 | 48 | # Make this unique, and don't share it with anybody. 49 | SECRET_KEY = "i!8c$-3sc+6+t$rma%l6(ux9ma8pq7f!h03=y!kx_hdf#*!y%o" 50 | 51 | TEMPLATES = [ 52 | { 53 | "BACKEND": "django.template.backends.django.DjangoTemplates", 54 | "DIRS": [ 55 | os.path.join(os.path.dirname(__file__), "templates"), 56 | ], 57 | } 58 | ] 59 | 60 | MIDDLEWARE = ( 61 | "django.middleware.common.CommonMiddleware", 62 | "django.contrib.sessions.middleware.SessionMiddleware", 63 | "django.middleware.csrf.CsrfViewMiddleware", 64 | "django.contrib.auth.middleware.AuthenticationMiddleware", 65 | "django.contrib.messages.middleware.MessageMiddleware", 66 | # Uncomment the next line for simple clickjacking protection: 67 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 68 | ) 69 | 70 | ROOT_URLCONF = "tests.urls" 71 | 72 | INSTALLED_APPS = ( 73 | "django.contrib.auth", 74 | "django.contrib.contenttypes", 75 | "django.contrib.sessions", 76 | "django.contrib.messages", 77 | "django.contrib.staticfiles", 78 | "coconuts", 79 | ) 80 | 81 | # FIXME: make this windows-friendly 82 | tmp_dir = "/tmp/coconuts.test.%s" % getpass.getuser() 83 | COCONUTS_CACHE_ROOT = os.path.join(tmp_dir, "cache") 84 | COCONUTS_DATA_ROOT = os.path.join(tmp_dir, "data") 85 | COCONUTS_FRONTEND_ROOT = os.path.join(tmp_dir, "frontend") 86 | -------------------------------------------------------------------------------- /tests/templates/registration/login.html: -------------------------------------------------------------------------------- 1 |

SOME LOGIN VIEW

2 | -------------------------------------------------------------------------------- /tests/test_content.py: -------------------------------------------------------------------------------- 1 | from tests import BaseTest 2 | 3 | 4 | class EmptyFolderContentTest(BaseTest): 5 | fixtures = ["test_users.json"] 6 | 7 | def test_home_as_anonymous(self): 8 | """ 9 | Anonymous users need to login. 10 | """ 11 | response = self.client.get("/images/contents/") 12 | self.assertEqual(response.status_code, 401) 13 | 14 | def test_home_as_user(self): 15 | """ 16 | Authenticated user can browse the home folder. 17 | """ 18 | self.client.login(username="test_user_1", password="test") 19 | response = self.client.get("/images/contents/") 20 | self.assertJson( 21 | response, 22 | { 23 | "files": [], 24 | "folders": [], 25 | "name": "", 26 | "path": "/", 27 | }, 28 | ) 29 | 30 | 31 | class FolderContentTest(BaseTest): 32 | files = [ 33 | ".test.txt", 34 | "test.jpg", 35 | "test_finepix.jpg", 36 | "test.mp4", 37 | "test.png", 38 | "test.txt", 39 | ] 40 | fixtures = ["test_users.json"] 41 | folders = ["Foo"] 42 | 43 | def test_file_as_anonymous(self): 44 | response = self.client.get("/images/contents/test.jpg") 45 | self.assertEqual(response.status_code, 401) 46 | 47 | def test_file_as_user(self): 48 | self.client.login(username="test_user_1", password="test") 49 | response = self.client.get("/images/contents/test.jpg") 50 | self.assertEqual(response.status_code, 404) 51 | 52 | def test_folder_as_user(self): 53 | self.client.login(username="test_user_1", password="test") 54 | 55 | with self.subTest("No trailing slash"): 56 | response = self.client.get("/images/contents/Foo") 57 | self.assertJson( 58 | response, {"files": [], "folders": [], "name": "Foo", "path": "/Foo/"} 59 | ) 60 | 61 | with self.subTest("With trailing slash"): 62 | response = self.client.get("/images/contents/Foo/") 63 | self.assertJson( 64 | response, {"files": [], "folders": [], "name": "Foo", "path": "/Foo/"} 65 | ) 66 | 67 | def test_home_as_anonymous(self): 68 | """ 69 | Anonymous users need to login. 70 | """ 71 | response = self.client.get("/images/contents/") 72 | self.assertEqual(response.status_code, 401) 73 | 74 | def test_home_as_user(self): 75 | """ 76 | Authenticated user can browse the home folder. 77 | """ 78 | self.client.login(username="test_user_1", password="test") 79 | response = self.client.get("/images/contents/") 80 | self.assertJson( 81 | response, 82 | { 83 | "files": [ 84 | { 85 | "image": { 86 | "width": 4272, 87 | "height": 2848, 88 | "camera": "Canon EOS 450D", 89 | "settings": "f/10, 1/125\xa0sec, 48\xa0mm", 90 | }, 91 | "mimetype": "image/jpeg", 92 | "name": "test.jpg", 93 | "path": "/test.jpg", 94 | "size": 5370940, 95 | }, 96 | { 97 | "mimetype": "video/mp4", 98 | "name": "test.mp4", 99 | "path": "/test.mp4", 100 | "size": 1055736, 101 | "video": { 102 | "duration": 5.28, 103 | "height": 720, 104 | "width": 1280, 105 | }, 106 | }, 107 | { 108 | "image": {"width": 24, "height": 24}, 109 | "mimetype": "image/png", 110 | "name": "test.png", 111 | "path": "/test.png", 112 | "size": 548, 113 | }, 114 | { 115 | "mimetype": "text/plain", 116 | "name": "test.txt", 117 | "path": "/test.txt", 118 | "size": 6, 119 | }, 120 | { 121 | "image": { 122 | "camera": "FUJIFILM FinePix F810", 123 | "height": 3040, 124 | "settings": "f/5, 1/420\xa0sec, 7.2\xa0mm", 125 | "width": 4048, 126 | }, 127 | "mimetype": "image/jpeg", 128 | "name": "test_finepix.jpg", 129 | "path": "/test_finepix.jpg", 130 | "size": 2548043, 131 | }, 132 | ], 133 | "folders": [ 134 | { 135 | "mimetype": "inode/directory", 136 | "name": "Foo", 137 | "path": "/Foo/", 138 | }, 139 | ], 140 | "name": "", 141 | "path": "/", 142 | }, 143 | ) 144 | -------------------------------------------------------------------------------- /tests/test_download.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | from tests import BaseTest 4 | 5 | 6 | class DownloadFileTest(BaseTest): 7 | files = ["test.jpg"] 8 | fixtures = ["test_users.json"] 9 | 10 | def test_as_anonymous(self): 11 | """ 12 | Anonymous user cannot render a file. 13 | """ 14 | # bad path 15 | response = self.client.get("/images/download/notfound.jpg") 16 | self.assertEqual(response.status_code, 302) 17 | 18 | # good path 19 | response = self.client.get("/images/download/test.jpg") 20 | self.assertEqual(response.status_code, 302) 21 | 22 | def test_as_user(self): 23 | """ 24 | Authenticated user can download a file. 25 | """ 26 | self.client.login(username="test_user_1", password="test") 27 | 28 | # bad path 29 | response = self.client.get("/images/download/notfound.jpg") 30 | self.assertEqual(response.status_code, 404) 31 | 32 | # good path 33 | response = self.client.get("/images/download/test.jpg") 34 | self.assertImage( 35 | response, 36 | content_type="image/jpeg", 37 | content_disposition='attachment; filename="test.jpg"', 38 | image_size=(4272, 2848), 39 | ) 40 | 41 | @override_settings(COCONUTS_DATA_ACCEL="/coconuts-data/") 42 | def test_as_user_accel(self): 43 | """ 44 | Authenticated user can download a file with acceleration. 45 | """ 46 | self.client.login(username="test_user_1", password="test") 47 | 48 | # bad path 49 | response = self.client.get("/images/download/notfound.jpg") 50 | self.assertEqual(response.status_code, 404) 51 | 52 | # good path 53 | response = self.client.get("/images/download/test.jpg") 54 | self.assertImageAccel( 55 | response, 56 | content_type="image/jpeg", 57 | x_accel_redirect="/coconuts-data/test.jpg", 58 | ) 59 | -------------------------------------------------------------------------------- /tests/test_exif.py: -------------------------------------------------------------------------------- 1 | from PIL.TiffImagePlugin import IFDRational 2 | 3 | from coconuts.views import format_rational 4 | from tests import BaseTest 5 | 6 | 7 | class ExifRationalTest(BaseTest): 8 | fixtures = ["test_users.json"] 9 | 10 | def test_canon(self): 11 | """ 12 | IMG_8232.JPG 13 | """ 14 | # fnumber 15 | self.assertEqual(format_rational(IFDRational(4, 1)), "4") 16 | 17 | # exposure time 18 | self.assertEqual(format_rational(IFDRational(1, 80)), "1/80") 19 | 20 | def test_canon_450d(self): 21 | # fnumber 22 | self.assertEqual(format_rational(IFDRational(10, 1)), "10") 23 | 24 | # exposure time 25 | self.assertEqual(format_rational(IFDRational(0.008)), "1/125") 26 | 27 | def test_fujifilm(self): 28 | """ 29 | DSCF1900.JPG 30 | """ 31 | # fnumber 32 | self.assertEqual(format_rational(IFDRational(560, 100)), "5.6") 33 | 34 | # FIXME: exposure time! 35 | self.assertEqual(format_rational(IFDRational(0.007142857142857143)), "1/140") 36 | -------------------------------------------------------------------------------- /tests/test_index.py: -------------------------------------------------------------------------------- 1 | from tests import BaseTest 2 | 3 | 4 | class HomeTest(BaseTest): 5 | fixtures = ["test_users.json"] 6 | 7 | def test_home_as_anonymous(self): 8 | """ 9 | Anonymous user needs to login. 10 | """ 11 | response = self.client.get("/") 12 | self.assertRedirects(response, "/accounts/login/?next=/") 13 | 14 | def test_home_as_user(self): 15 | """ 16 | Authenticated user can browse home. 17 | """ 18 | self.client.login(username="test_user_1", password="test") 19 | response = self.client.get("/") 20 | self.assertEqual(response.status_code, 200) 21 | 22 | def test_folder_as_anonymous(self): 23 | """ 24 | Anonymous user needs to login. 25 | """ 26 | response = self.client.get("/other/") 27 | self.assertRedirects(response, "/accounts/login/?next=/other/") 28 | 29 | def test_folder_as_user(self): 30 | """ 31 | Authenticated user can browse folder. 32 | """ 33 | self.client.login(username="test_user_1", password="test") 34 | response = self.client.get("/other/") 35 | self.assertEqual(response.status_code, 200) 36 | 37 | def test_static_as_user(self): 38 | """ 39 | Authenticated user can browse static. 40 | """ 41 | self.client.login(username="test_user_1", password="test") 42 | response = self.client.get("/test.css") 43 | self.assertEqual(response.status_code, 200) 44 | -------------------------------------------------------------------------------- /tests/test_path.py: -------------------------------------------------------------------------------- 1 | from coconuts.views import clean_path 2 | from tests import BaseTest 3 | 4 | 5 | class PathTest(BaseTest): 6 | def test_clean(self): 7 | self.assertEqual(clean_path(""), "") 8 | self.assertEqual(clean_path("."), "") 9 | self.assertEqual(clean_path(".."), "") 10 | self.assertEqual(clean_path("/"), "") 11 | self.assertEqual(clean_path("/foo"), "foo") 12 | self.assertEqual(clean_path("/foo/"), "foo") 13 | self.assertEqual(clean_path("/foo/bar"), "foo/bar") 14 | self.assertEqual(clean_path("/foo/bar/"), "foo/bar") 15 | 16 | def test_clean_bad(self): 17 | with self.assertRaises(ValueError): 18 | clean_path("\\") 19 | with self.assertRaises(ValueError): 20 | clean_path("\\foo") 21 | -------------------------------------------------------------------------------- /tests/test_render.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | from tests import BaseTest 4 | 5 | 6 | class RenderFileTest(BaseTest): 7 | files = [ 8 | "test.jpg", 9 | "test.mp4", 10 | "test.png", 11 | "test.txt", 12 | "test_portrait.jpg", 13 | "test_portrait.mp4", 14 | "test_rotated.jpg", 15 | "test_rotated.mp4", 16 | ] 17 | fixtures = ["test_users.json"] 18 | 19 | def test_as_anonymous(self): 20 | """ 21 | Anonymous user cannot render a file. 22 | """ 23 | # no size 24 | response = self.client.get("/images/render/test.jpg") 25 | self.assertEqual(response.status_code, 401) 26 | 27 | # bad size 28 | response = self.client.get("/images/render/test.jpg?size=123") 29 | self.assertEqual(response.status_code, 401) 30 | 31 | # good size, bad type 32 | response = self.client.get("/images/render/test.txt?size=1024") 33 | self.assertEqual(response.status_code, 401) 34 | 35 | # good size, good path 36 | response = self.client.get("/images/render/test.jpg?size=1024") 37 | self.assertEqual(response.status_code, 401) 38 | 39 | # good size, good path 40 | response = self.client.get("/images/render/test.png?size=1024") 41 | self.assertEqual(response.status_code, 401) 42 | 43 | def test_as_user_bad(self): 44 | """ 45 | Authenticated user can render a file. 46 | """ 47 | self.client.login(username="test_user_1", password="test") 48 | 49 | # no size 50 | response = self.client.get("/images/render/test.jpg") 51 | self.assertEqual(response.status_code, 400) 52 | 53 | # bad size 54 | response = self.client.get("/images/render/test.jpg?size=123") 55 | self.assertEqual(response.status_code, 400) 56 | 57 | # good size, bad path 58 | response = self.client.get("/images/render/notfound.jpg?size=1024") 59 | self.assertEqual(response.status_code, 404) 60 | 61 | # good size, bad type 62 | response = self.client.get("/images/render/test.txt?size=1024") 63 | self.assertEqual(response.status_code, 400) 64 | 65 | def test_as_user_good_image(self): 66 | self.client.login(username="test_user_1", password="test") 67 | 68 | with self.subTest("landscape - jpeg"): 69 | # cache miss 70 | response = self.client.get("/images/render/test.jpg?size=1024") 71 | self.assertImage( 72 | response, 73 | content_type="image/jpeg", 74 | image_size=(1024, 683), 75 | ) 76 | 77 | # cache hit 78 | response = self.client.get("/images/render/test.jpg?size=1024") 79 | self.assertImage( 80 | response, 81 | content_type="image/jpeg", 82 | image_size=(1024, 683), 83 | ) 84 | 85 | with self.subTest("landscape - png"): 86 | response = self.client.get("/images/render/test.png?size=1024") 87 | self.assertImage( 88 | response, 89 | content_type="image/png", 90 | image_size=(24, 24), 91 | ) 92 | 93 | with self.subTest("portrait - jpeg"): 94 | response = self.client.get("/images/render/test_portrait.jpg?size=1024") 95 | self.assertImage( 96 | response, 97 | content_type="image/jpeg", 98 | image_size=(512, 768), 99 | ) 100 | 101 | with self.subTest("rotated - jpeg"): 102 | response = self.client.get("/images/render/test_rotated.jpg?size=1024") 103 | self.assertImage( 104 | response, 105 | content_type="image/jpeg", 106 | image_size=(512, 768), 107 | ) 108 | 109 | @override_settings(COCONUTS_CACHE_ACCEL="/coconuts-cache/") 110 | def test_as_user_good_image_accel(self): 111 | self.client.login(username="test_user_1", password="test") 112 | 113 | response = self.client.get("/images/render/test.jpg?size=1024") 114 | self.assertImageAccel( 115 | response, 116 | content_type="image/jpeg", 117 | x_accel_redirect="/coconuts-cache/1024/test.jpg", 118 | ) 119 | 120 | def test_as_user_good_video(self): 121 | self.client.login(username="test_user_1", password="test") 122 | 123 | with self.subTest("landscape"): 124 | # cache miss 125 | response = self.client.get("/images/render/test.mp4?size=1024") 126 | self.assertImage( 127 | response, 128 | content_type="image/jpeg", 129 | image_size=(1024, 576), 130 | ) 131 | 132 | # cache hit 133 | response = self.client.get("/images/render/test.mp4?size=1024") 134 | self.assertImage( 135 | response, 136 | content_type="image/jpeg", 137 | image_size=(1024, 576), 138 | ) 139 | 140 | with self.subTest("portrait"): 141 | response = self.client.get("/images/render/test_portrait.mp4?size=1024") 142 | self.assertImage( 143 | response, 144 | content_type="image/jpeg", 145 | image_size=(432, 768), 146 | ) 147 | 148 | with self.subTest("rotated"): 149 | response = self.client.get("/images/render/test_rotated.mp4?size=1024") 150 | self.assertImage( 151 | response, 152 | content_type="image/jpeg", 153 | image_size=(432, 768), 154 | ) 155 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import views 2 | from django.urls import include, path 3 | 4 | urlpatterns = [ 5 | # accounts 6 | path("accounts/login/", views.LoginView.as_view()), 7 | path("accounts/logout/", views.LogoutView.as_view()), 8 | # folders 9 | path("", include("coconuts.urls")), 10 | ] 11 | --------------------------------------------------------------------------------