├── .gitignore ├── LICENSE ├── README.md ├── app.yaml ├── appengine_config.py ├── main.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | .pyre/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Albert Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Resizing and Serving images on Google Cloud Storage 2 | ================================== 3 | 4 | Resize your image files on Google Cloud storage with [Images Python API](https://developers.google.com/appengine/docs/python/images/) powered by Google. 5 | 6 | ### Important note ☢️ 7 | 8 | This project runs on the App Engine Python 2.7 Runtime and, even though Python 2.7 is not maintaned anymore, Google has [committed to providing long term support for the App Engine Python 2.7 runtime](https://cloud.google.com/appengine/docs/standard/long-term-support#our_commitment), continuing their _"more than decade-long history of supporting your apps"_. 9 | 10 | Still, you need to be aware that: 11 | > As communities stop maintaining versions of their languages, your app may be exposed to vulnerabilities for which no publicly available fix exists. Thus, continuing to run your app in some App Engine runtimes involves more risk than upgrading to a runtime that has a community supported language. 12 | 13 | Also, you should know that if Google ever decides to deprecate any of the APIs used by this project, it will first be announced at their [deprecations page](https://cloud.google.com/appengine/docs/deprecations/). 14 | 15 | For more discussions on this topic, please refer to [issue #3](https://github.com/albertcht/python-gcs-image/issues/3). 16 | 17 | ## Setup 18 | 19 | 1. Clone this repo. 20 | 21 | ``` 22 | git clone https://github.com/albertcht/python-gcs-image.git 23 | ``` 24 | 25 | 2. Install the requirements. (Flask) 26 | 27 | ``` 28 | pip install -r requirements.txt -t lib 29 | ``` 30 | 31 | 3. Deploy to App Engine. 32 | 33 | ``` 34 | gcloud app deploy 35 | ``` 36 | 37 | ## Usage 38 | 39 | 1. Get a serving url from existed file on Google Cloud Storage: 40 | 41 | ``` 42 | curl https://PROJECT_NAME.appspot.com/image-url?bucket=mybuckey&image=image_name.jpg 43 | ``` 44 | 45 | 2. It will return a url that looks something like: 46 | 47 | ``` 48 | https://lh3.googleusercontent.com/93uhV8K2yHkRuD63KJxlTi7SxjHS8my2emuHmGLZxEmX99_XAjTN3c_2zmKVb3XQ5d8FEkwtgbGjyYpaDQg 49 | ``` 50 | 51 | ## Reminders 52 | 53 | 1. Only one app can "own" the image. As stated in the [documentation](https://developers.google.com/appengine/docs/python/images/functions) for get_serving_url: 54 | 55 | > If you serve images from Google Cloud Storage, you cannot serve an image from two separate apps. Only the first app that calls get_serving_url on the image can get the URL to serve it because that app has obtained ownership of the image. 56 | 57 | 2. The serving url is inherently public (no support for private serving urls). 58 | 59 | ## Google Cloud Storage Setup 60 | 61 | Note you need to grant **Storage Object Admin** access on your GCS objects to a GAE service account responsible for generating URLs, which looks like: 62 | 63 | ``` 64 | your-project-id@appspot.gserviceaccount.com 65 | ``` 66 | 67 | ## gsutil Commands for Reference 68 | 69 | ``` 70 | # Create a new bucket. 71 | gsutil mb -p gs:// 72 | 73 | # Set the default ACL for objects uploaded to the bucket. Note the below 74 | # command grants OWNER access to the service account. 75 | gsutil defacl ch -u account@example.com:O gs:// 76 | 77 | # If using the web uploader you will also need to grant access to the service 78 | # account to create objects. 79 | gsutil acl ch -u account@example.com:O gs:// 80 | 81 | # Ensure that the files are set with `public-read` permissions. URLs generated 82 | # by the images service respect GCS object permissions so if you intend to serve 83 | # them publicly, they will need to be `public-read`. Adjust the default ACL with 84 | # the command below. 85 | gsutil defacl set public-read gs:// 86 | 87 | # Upload assets to the Google Cloud Storage bucket. 88 | gsutil cp file.jpg gs://// 89 | ``` 90 | 91 | ## Examples 92 | 93 | * By default it returns an image of a maximum length of 512px. [(link)](https://lh3.googleusercontent.com/93uhV8K2yHkRuD63KJxlTi7SxjHS8my2emuHmGLZxEmX99_XAjTN3c_2zmKVb3XQ5d8FEkwtgbGjyYpaDQg) 94 | 95 | * By appending the =sXX to the end of it where XX can be any integer in the range of 0–1600 and it will result to scale down the image to longest dimension without affecting the original aspect ratio. [(link =s256)](https://lh3.googleusercontent.com/93uhV8K2yHkRuD63KJxlTi7SxjHS8my2emuHmGLZxEmX99_XAjTN3c_2zmKVb3XQ5d8FEkwtgbGjyYpaDQg=s256) 96 | 97 | * By appending =sXX-c a cropped version of that image is being returned as a response. [(link =s400-c)](https://lh3.googleusercontent.com/93uhV8K2yHkRuD63KJxlTi7SxjHS8my2emuHmGLZxEmX99_XAjTN3c_2zmKVb3XQ5d8FEkwtgbGjyYpaDQg=s400-c) 98 | 99 | * By appending =pp-br100-rp-s200 the image is smartly cropped, border 100%, format PNG and size is 200. [(link =pp-br100-rp-s200)](https://lh3.googleusercontent.com/93uhV8K2yHkRuD63KJxlTi7SxjHS8my2emuHmGLZxEmX99_XAjTN3c_2zmKVb3XQ5d8FEkwtgbGjyYpaDQg=pp-br100-rp-s200) 100 | 101 | * By appending =pp-br100-rp-s200 the image is smartly cropped, width 100, height 300, quality 100, format JPG. [(link =w100-h300-c-pp-l100-rj)](https://lh3.googleusercontent.com/93uhV8K2yHkRuD63KJxlTi7SxjHS8my2emuHmGLZxEmX99_XAjTN3c_2zmKVb3XQ5d8FEkwtgbGjyYpaDQg=w100-h300-c-pp-l100-rj) 102 | 103 | * By appending =s0 (s zero) the original image is being returned without any resize or modification. [(link =s0)](https://lh3.googleusercontent.com/93uhV8K2yHkRuD63KJxlTi7SxjHS8my2emuHmGLZxEmX99_XAjTN3c_2zmKVb3XQ5d8FEkwtgbGjyYpaDQg=s0) 104 | 105 | 106 | ## Advanced Parameters 107 | 108 | ### SIZE / CROP 109 | 110 | * **s640** — generates image 640 pixels on largest dimension 111 | * **s0** — original size image 112 | * **w100** — generates image 100 pixels wide 113 | * **h100** — generates image 100 pixels tall 114 | * **s** (without a value) — stretches image to fit dimensions 115 | * **c** — crops image to provided dimensions 116 | * **n** — same as c, but crops from the center 117 | * **p** — smart square crop, attempts cropping to faces 118 | * **pp** — alternate smart square crop, does not cut off faces (?) 119 | * **cc** — generates a circularly cropped image 120 | * **ci** — square crop to smallest of: width, height, or specified =s parameter 121 | * **nu** — no-upscaling. Disables resizing an image to larger than its original resolution. 122 | 123 | ### ROTATION 124 | 125 | * **fv** — flip vertically 126 | * **fh** — flip horizontally 127 | * **r{90, 180, 270}** — rotates image 90, 180, or 270 degrees clockwise 128 | 129 | ### IMAGE FORMAT 130 | 131 | * **rj** — forces the resulting image to be JPG 132 | * **rp** — forces the resulting image to be PNG 133 | * **rw** — forces the resulting image to be WebP 134 | * **rg** — forces the resulting image to be GIF 135 | 136 | * **v{0,1,2,3}** — sets image to a different format option (works with JPG and WebP) 137 | 138 | > Forcing PNG, WebP and GIF outputs can work in combination with circular crops for a transparent background. Forcing JPG can be combined with border color to fill in backgrounds in transparent images. 139 | 140 | ### ANIMATED GIFs 141 | 142 | * **rh** — generates an MP4 from the input image 143 | * **k** — kill animation (generates static image) 144 | 145 | ### Filters 146 | 147 | * **fSoften=1,100,0**: - where 100 can go from 0 to 100 to blur the image 148 | * **fVignette=1,100,1.4,0,000000** where 100 controls the size of the gradient and 000000 is RRGGBB of the color of the border shadow 149 | * **fInvert=0,1** inverts the image regardless of the value provided 150 | * **fbw=0,1** makes the image black and white regardless of the value provided 151 | 152 | ### MISC. 153 | 154 | * **b10** — add a 10px border to image 155 | * **c0xAARRGGBB** — set border color, eg. =c0xffff0000 for red 156 | * **d** — adds header to cause browser download 157 | * **e7** — set cache-control max-age header on response to 7 days 158 | * **l100** — sets JPEG quality to 100% (1-100) 159 | * **h** — responds with an HTML page containing the image 160 | * **g** — responds with XML used by Google's pan/zoom 161 | 162 | ## Full Reference 163 | 164 | ``` 165 | int: s ==> Size 166 | int: w ==> Width 167 | bool: c ==> Crop 168 | hex: c ==> BorderColor 169 | bool: d ==> Download 170 | int: h ==> Height 171 | bool: s ==> Stretch 172 | bool: h ==> Html 173 | bool: p ==> SmartCrop 174 | bool: pa ==> PreserveAspectRatio 175 | bool: pd ==> Pad 176 | bool: pp ==> SmartCropNoClip 177 | bool: pf ==> SmartCropUseFace 178 | int: p ==> FocalPlane 179 | bool: n ==> CenterCrop 180 | int: r ==> Rotate 181 | bool: r ==> SkipRefererCheck 182 | bool: fh ==> HorizontalFlip 183 | bool: fv ==> VerticalFlip 184 | bool: cc ==> CircleCrop 185 | bool: ci ==> ImageCrop 186 | bool: o ==> Overlay 187 | str: o ==> EncodedObjectId 188 | str: j ==> EncodedFrameId 189 | int: x ==> TileX 190 | int: y ==> TileY 191 | int: z ==> TileZoom 192 | bool: g ==> TileGeneration 193 | bool: fg ==> ForceTileGeneration 194 | bool: ft ==> ForceTransformation 195 | int: e ==> ExpirationTime 196 | str: f ==> ImageFilter 197 | bool: k ==> KillAnimation 198 | int: k ==> FocusBlur 199 | bool: u ==> Unfiltered 200 | bool: ut ==> UnfilteredWithTransforms 201 | bool: i ==> IncludeMetadata 202 | bool: ip ==> IncludePublicMetadata 203 | bool: a ==> EsPortraitApprovedOnly 204 | int: a ==> SelectFrameint 205 | int: m ==> VideoFormat 206 | int: vb ==> VideoBegin 207 | int: vl ==> VideoLength 208 | bool: lf ==> LooseFaceCrop 209 | bool: mv ==> MatchVersion 210 | bool: id ==> ImageDigest 211 | int: ic ==> InternalClient 212 | bool: b ==> BypassTakedown 213 | int: b ==> BorderSize 214 | str: t ==> Token 215 | str: nt0 ==> VersionedToken 216 | bool: rw ==> RequestWebp 217 | bool: rwu ==> RequestWebpUnlessMaybeTransparent 218 | bool: rwa ==> RequestAnimatedWebp 219 | bool: nw ==> NoWebp 220 | bool: rh ==> RequestH264 221 | bool: nc ==> NoCorrectExifOrientation 222 | bool: nd ==> NoDefaultImage 223 | bool: no ==> NoOverlay 224 | str: q ==> QueryString 225 | bool: ns ==> NoSilhouette 226 | int: l ==> QualityLevel 227 | int: v ==> QualityBucket 228 | bool: nu ==> NoUpscale 229 | bool: rj ==> RequestJpeg 230 | bool: rp ==> RequestPng 231 | bool: rg ==> RequestGif 232 | bool: pg ==> TilePyramidAsProto 233 | bool: mo ==> Monogram 234 | bool: al ==> Autoloop 235 | int: iv ==> ImageVersion 236 | int: pi ==> PitchDegrees 237 | int: ya ==> YawDegrees 238 | int: ro ==> RollDegrees 239 | int: fo ==> FovDegrees 240 | bool: df ==> DetectFaces 241 | str: mm ==> VideoMultiFormat 242 | bool: sg ==> StripGoogleData 243 | bool: gd ==> PreserveGoogleData 244 | bool: fm ==> ForceMonogram 245 | int: ba ==> Badge 246 | int: br ==> BorderRadius 247 | hex: bc ==> BackgroundColor 248 | hex: pc ==> PadColor 249 | hex: sc ==> SubstitutionColor 250 | bool: dv ==> DownloadVideo 251 | bool: md ==> MonogramDogfood 252 | int: cp ==> ColorProfile 253 | bool: sm ==> StripMetadata 254 | int: cv ==> FaceCropVersion 255 | ``` 256 | 257 | > Reference: 258 | > * https://stackoverflow.com/questions/25148567/list-of-all-the-app-engine-images-service-get-serving-url-uri-options 259 | > * https://medium.com/google-cloud/uploading-resizing-and-serving-images-with-google-cloud-platform-ca9631a2c556 260 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | api_version: 1 2 | runtime: python27 3 | threadsafe: true 4 | 5 | libraries: 6 | - name: ssl 7 | version: latest 8 | 9 | handlers: 10 | - url: /static 11 | static_dir: static 12 | - url: /.* 13 | script: main.app -------------------------------------------------------------------------------- /appengine_config.py: -------------------------------------------------------------------------------- 1 | """Add the lib directory to the path, so you can use libraries.""" 2 | 3 | import sys 4 | import os.path 5 | sys.path.append(os.path.join(os.path.dirname(__file__), 'lib')) 6 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import json 4 | 5 | from flask import Flask 6 | from flask import request 7 | from flask import make_response 8 | 9 | from google.appengine.ext import blobstore 10 | from google.appengine.api import images 11 | 12 | JSON_MIME_TYPE = 'application/json' 13 | 14 | app = Flask(__name__) 15 | 16 | @app.route('/image-url', methods=['GET']) 17 | def image_url(): 18 | bucket = request.args.get('bucket') 19 | image = request.args.get('image') 20 | 21 | if not all([bucket, image]): 22 | error = json.dumps({'error': 'Missing `bucket` or `image` parameter.'}) 23 | return json_response(error, 422) 24 | 25 | filepath = (bucket + "/" + image) 26 | 27 | try: 28 | servingImage = images.get_serving_url(None, filename='/gs/' + filepath) 29 | except images.AccessDeniedError: 30 | error = json.dumps({'error': 'Ensure the GAE service account has access to the object in Google Cloud Storage.'}) 31 | return json_response(error, 401) 32 | except images.ObjectNotFoundError: 33 | error = json.dumps({'error': 'The object was not found.'}) 34 | return json_response(error, 404) 35 | except images.TransformationError: 36 | # A TransformationError may happen in several scenarios - if 37 | # the file is simply too large for the images service to 38 | # handle, if the image service doesn't have access to the file, 39 | # or if the file was already uploaded to the image service by 40 | # another App Engine app. For the latter case, we can try to 41 | # work around that by copying the file and re-uploading it to 42 | # the image service. 43 | error = json.dumps({'error': 'There was a problem transforming the image. Ensure the GAE service account has access to the object in Google Cloud Storage.'}) 44 | return json_response(error, 400) 45 | 46 | return json_response(json.dumps({'image_url': servingImage})) 47 | 48 | def json_response(data='', status=200, headers=None): 49 | headers = headers or {} 50 | if 'Content-Type' not in headers: 51 | headers['Content-Type'] = JSON_MIME_TYPE 52 | 53 | return make_response(data, status, headers) 54 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.0.2 2 | Werkzeug==0.14.1 3 | google-api-python-client==1.7.7 4 | GoogleAppEngineCloudStorageClient==1.9.22.1 --------------------------------------------------------------------------------