A private party media vault built with Flask and Google Cloud Storage.
CODE GENERATED ENTIRELY WITH AI TOOLS (CHATGPT and GEMINI)
Users can:
- Upload multiple images
- Take photos directly from supported mobile devices
- Upload images later from their device
- Upload videos directly to Google Cloud Storage
- View recent photos and videos
- View all photos in a masonry-style camera roll
- See the current number of photos and videos
- Load photos progressively with pagination
- Open full-resolution images only when selected
- Automatically generate thumbnails for uploaded images
- Upload HEIC images
- Generate secure signed URLs for direct video uploads
- Automatically pause videos when they scroll out of view
- Use a password-protected admin panel
- Delete photos from the admin dashboard
- Delete videos from a separate admin video management page
βββββββββββββββββββββ
β β
β User β
β Browser β
β β
βββββββββββ¬ββββββββββ
β
βΌ
ββββββββββββββββββ
β β
β Cloud Run β
β Flask App β
β β
βββββββββ¬βββββββββ
β
ββββββββββββββββ΄βββββββββββββββ
β β
βΌ βΌ
Image Upload Signed Video URL
β β
βΌ βΌ
Flask receives Browser uploads
image files directly to GCS
β β
βΌ β
Google Cloud Storage βββββββββββββββββ
β
βββββββββββββΌββββββββββββ
βΌ βΌ βΌ
images/ thumbnails/ videos/
Recommended project structure:
partyphotos/
β
βββ app.py
βββ requirements.txt
βββ Dockerfile
βββ .dockerignore
βββ .gitignore
βββ README.md
β
βββ .env
β
βββ service-account.json
β
βββ templates/
β
βββ base.html
βββ index.html
βββ images.html
βββ videos.html
βββ admin.html
βββ admin_videos.html
Never commit
.envorservice-account.jsonto GitHub.
This project requires Python 3.11+.
Python 3.13 should also work with the current dependencies.
Create a virtual environment:
python3 -m venv venvActivate it on macOS/Linux:
source venv/bin/activateOn Windows:
venv\Scripts\activateInstall dependencies:
pip install -r requirements.txtCreate a file called:
requirements.txt
Add:
Flask
google-cloud-storage
google-auth
python-dotenv
Pillow
pillow-heif
gunicorn
Then install:
pip install -r requirements.txtCreate a .env file in the root of the project:
BUCKET_NAME=your-google-cloud-storage-bucket-name
APP_FLASK_SECRET_KEY=replace-this-with-a-long-random-secret-key
ADMIN_PASSWORD=replace-this-with-a-strong-admin-password
FLASK_DEBUG=trueExample:
BUCKET_NAME=party28
APP_FLASK_SECRET_KEY=some-long-random-secret-key
ADMIN_PASSWORD=your-private-admin-password
FLASK_DEBUG=trueThe name of your Google Cloud Storage bucket.
Example:
BUCKET_NAME=party28The bucket should contain:
party28/
β
βββ images/
β
βββ thumbnails/
β
βββ videos/
The folders do not necessarily need to be manually created.
Google Cloud Storage creates object prefixes when files are uploaded.
Used by Flask for:
- Sessions
- Admin authentication sessions
- Flash messages
Generate a secure secret key:
python -c "import secrets; print(secrets.token_hex(32))"Example:
APP_FLASK_SECRET_KEY=8a9d4b...Never commit this value to GitHub.
The password required to access:
/admin
Example:
ADMIN_PASSWORD=your-strong-passwordThe admin password should be configured as an environment variable.
For Cloud Run, configure it in the Cloud Run service environment variables or secrets configuration.
Do not hardcode it in:
- app.py
- HTML templates
- JavaScript
- GitHub repositories
For local development:
FLASK_DEBUG=trueFor production:
FLASK_DEBUG=falseCloud Run should always run with:
FLASK_DEBUG=falseCreate or use a Google Cloud project.
The current project uses:
partyphotos-501522
If you use a different project, update the relevant configuration.
Enable the required APIs:
gcloud services enable storage.googleapis.comAlso enable:
gcloud services enable iamcredentials.googleapis.comThe IAM Credentials API is required for generating signed URLs using the Cloud Run service account.
Create a bucket:
gcloud storage buckets create gs://YOUR_BUCKET_NAME \
--location=us-central1Example:
gcloud storage buckets create gs://party28 \
--location=us-central1Your bucket name must match:
BUCKET_NAME=party28The application uses:
images/
thumbnails/
videos/
as object prefixes.
Create a service account for the application:
gcloud iam service-accounts create partyphotoslocal \
--display-name="Party Photos Application"This creates an email similar to:
partyphotoslocal@YOUR_PROJECT_ID.iam.gserviceaccount.com
The current project uses:
partyphotoslocal@partyphotos-501522.iam.gserviceaccount.com
The application uses this service account for Google Cloud Storage access and signed URL generation.
The service account needs permission to:
- Upload original images
- Upload thumbnails
- Read images
- Read thumbnails
- Read videos
- Delete objects through the admin panel
- Generate signed URLs
Grant the Storage Object Admin role:
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:YOUR_SERVICE_ACCOUNT_EMAIL" \
--role="roles/storage.objectAdmin"Example:
gcloud projects add-iam-policy-binding partyphotos-501522 \
--member="serviceAccount:partyphotoslocal@partyphotos-501522.iam.gserviceaccount.com" \
--role="roles/storage.objectAdmin"For signed video uploads, the service account needs permission to sign blobs.
Grant:
gcloud iam service-accounts add-iam-policy-binding \
partyphotoslocal@partyphotos-501522.iam.gserviceaccount.com \
--member="serviceAccount:partyphotoslocal@partyphotos-501522.iam.gserviceaccount.com" \
--role="roles/iam.serviceAccountTokenCreator"This permission is important for:
blob.generate_signed_url(
version="v4",
...
)For local development, create a service-account key.
Download the JSON credentials file from Google Cloud.
Save it locally as:
service-account.json
Example:
/Users/your-name/Desktop/partyphotos/service-account.json
Your local code can use:
if not os.getenv("K_SERVICE"):
os.environ[
"GOOGLE_APPLICATION_CREDENTIALS"
] = "/Users/your-name/Desktop/partyphotos/service-account.json"Important:
service-account.json
must never be uploaded to GitHub.
Create:
.gitignore
Add:
venv/
.env
service-account.json
__pycache__/
*.pyc
.DS_Store
This protects your credentials.
Images are uploaded to Flask:
Browser
β
POST /upload
β
Flask
β
Google Cloud Storage
β
images/
The original image is stored in:
images/
Example:
images/
βββ uuid_originalfilename.jpg
After the original image is uploaded, a background process creates a thumbnail.
The thumbnail is stored in:
thumbnails/
Example:
thumbnails/
βββ uuid_originalfilename.jpg
The frontend initially displays thumbnails instead of full-resolution images.
This improves:
- Page loading speed
- Mobile performance
- Data usage
- Gallery scrolling performance
The upload interface supports two main upload methods:
Take Photo
β
βΌ
Device Camera
β
βΌ
Selected File
β
βΌ
Upload
and:
Choose Memories
β
βΌ
Device File Picker
β
βΌ
Selected Files
β
βΌ
Upload
The camera input typically uses:
<input
type="file"
accept="image/*"
capture="environment"
>Important:
- Camera support depends on the browser and device.
- The file must be attached to the upload input before submitting.
- iOS and Android may handle multiple file selection differently.
- Direct camera capture generally selects one image at a time.
- Users can still upload older photos through the normal file picker.
iOS Safari can behave differently from Android browsers when selecting multiple files.
Recommended behavior:
- Use the normal file picker for multiple photos.
- Use the camera input for directly taking a new photo.
- Do not rely on the camera capture input for multiple image selection.
For multiple uploads:
<input
type="file"
accept="image/*"
multiple
>For direct camera capture:
<input
type="file"
accept="image/*"
capture="environment"
>These should be treated as separate upload experiences.
The application supports HEIC images using:
import pillow_heifand:
pillow_heif.register_heif_opener()This allows:
Image.open(...)to read HEIC images.
The thumbnail can be converted to JPEG:
img.save(
thumb_io,
format="JPEG",
quality=80
)The original HEIC file remains in Google Cloud Storage.
Videos do not pass through Flask.
Instead:
Browser
β
β 1. Request signed URL
βΌ
Flask
β
β 2. Generate temporary signed URL
βΌ
Browser
β
β 3. Upload video directly
βΌ
Google Cloud Storage
The signed URL is temporary.
The video is stored in:
videos/
Example:
videos/
βββ uuid_video.mp4
This is better than sending large videos through Flask because:
- Flask does not need to process the entire video.
- Cloud Run receives less traffic.
- Large video uploads are faster.
- Memory usage is reduced.
GET /
Displays:
- Upload interface
- Recent images
- Recent videos
- Image and video counts
GET /images
Displays the camera roll.
Features:
- Masonry-style layout
- Thumbnail-first loading
- Pagination
- Full-resolution image loading only when opened
- Current image count
Example:
Camera Roll
124 PHOTOS
GET /videos
Displays uploaded videos.
Features:
- Video playback
- Video count
- Pagination
- Automatic pause when a video scrolls out of view
POST /upload
Receives image files.
The maximum request size is currently:
app.config["MAX_CONTENT_LENGTH"] = 500 * 1024 * 1024This equals:
500 MB
GET /get-signed-url
Example:
/get-signed-url?filename=video.mp4&content_type=video/mp4
Returns:
{
"url": "temporary-signed-url"
}The videos page uses browser visibility detection.
When a video leaves the visible viewport:
User watches video
β
User scrolls down
β
Video leaves screen
β
Video automatically pauses
This prevents multiple videos from continuing to play while the user scrolls through the page.
This improves:
- Mobile battery usage
- Data usage
- User experience
- Audio control
A typical implementation uses:
IntersectionObserverto monitor video visibility.
The admin panel is available at:
/admin
The admin system uses:
ADMIN_PASSWORD
and a Flask session.
The flow is:
User
β
βΌ
/admin
β
βΌ
Enter password
β
βββ Incorrect
β β
β βΌ
β Login error
β
βββ Correct
β
βΌ
Admin session created
β
βΌ
Admin dashboard
After successful login:
session["is_admin"] = TrueThe session is protected using:
APP_FLASK_SECRET_KEY
The admin dashboard is:
/admin
After authentication, it displays:
- All uploaded images
- Image thumbnails
- Delete buttons
- Link to admin videos
- Logout button
The dashboard is intended to be the admin image management page.
The public navigation remains separate.
The image delete route should be protected by the admin session.
Example:
POST /admin/delete-image/<filename>
The route should:
- Check whether the user is authenticated.
- Delete the original image.
- Delete the corresponding thumbnail if it exists.
- Redirect back to the admin dashboard.
The image deletion action should require confirmation in the browser:
Delete this photo permanently?
Deletion is permanent unless Google Cloud Storage versioning is enabled.
Admin video management is separate from the public videos page.
The admin video page is:
/admin/videos
This page should display:
- Uploaded videos
- Video playback
- Delete buttons
- Admin navigation
- Logout button
The admin videos page must not redirect to:
/videos
The public page:
/videos
is for viewing videos.
The admin page:
/admin/videos
is for managing and deleting videos.
The video delete route should be protected by the admin session.
Example:
POST /admin/delete-video/<filename>
The route should:
- Check whether the user is authenticated.
- Delete the video object from:
videos/
- Redirect back to:
/admin/videos
The browser should ask for confirmation before deletion:
Delete this video permanently?
The public navigation contains:
π Home
πΈ Photos
π¬ Videos
The public navigation links:
/
/images
/videos
The admin management navigation should remain separate.
Recommended admin links:
πΈ Photos
π¬ Videos
πͺ Logout
Admin navigation:
/admin
/admin/videos
Do not change the public Photos link to the admin page.
Public users should continue to access:
/images
The admin should access:
/admin
for image management.
Activate the virtual environment:
source venv/bin/activateMake sure your .env exists.
Make sure your credentials exist:
service-account.json
Run:
python app.pyThe application runs at:
http://localhost:8080
or:
http://127.0.0.1:8080
Open:
http://127.0.0.1:8080/admin
to test the admin panel.
For local development:
FLASK_DEBUG=trueFor production:
FLASK_DEBUG=falseNever run production applications with:
debug=TrueCloud Run automatically provides credentials to the application.
Therefore, you should not upload:
service-account.json
to Cloud Run.
The application detects Cloud Run using:
if not os.getenv("K_SERVICE"):Locally:
GOOGLE_APPLICATION_CREDENTIALS
is used.
On Cloud Run:
Application Default Credentials
are used automatically.
Create:
Dockerfile
with:
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y \
libheif1 \
libheif-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=8080
CMD exec gunicorn \
--bind :$PORT \
--workers 1 \
--threads 8 \
--timeout 0 \
app:appCreate:
.dockerignore
Add:
venv/
.env
service-account.json
.git/
__pycache__/
*.pyc
.DS_Store
Build and deploy:
gcloud run deploy partyphotos \
--source . \
--region us-central1 \
--allow-unauthenticatedSet the environment variables:
gcloud run services update partyphotos \
--region us-central1 \
--set-env-vars BUCKET_NAME=party28,FLASK_DEBUG=falseSet the Flask secret:
gcloud run services update partyphotos \
--region us-central1 \
--set-env-vars APP_FLASK_SECRET_KEY="YOUR_SECRET_KEY"Set the admin password:
gcloud run services update partyphotos \
--region us-central1 \
--set-env-vars ADMIN_PASSWORD="YOUR_ADMIN_PASSWORD"For a production application, Secret Manager is preferable to storing sensitive values directly as plain environment variables.
The Cloud Run service should run as:
partyphotoslocal@partyphotos-501522.iam.gserviceaccount.com
Deploy using:
gcloud run deploy partyphotos \
--source . \
--region us-central1 \
--service-account partyphotoslocal@partyphotos-501522.iam.gserviceaccount.com \
--allow-unauthenticatedThis allows the application to use:
google.auth.default()without a local JSON file.
Never commit:
.env
service-account.json
Never hardcode:
APP_FLASK_SECRET_KEYNever hardcode:
ADMIN_PASSWORDNever expose your service-account private key.
Never place your Google Cloud private key inside:
templates/
static/
or any public directory.
The admin panel should always verify:
session.get("is_admin")before allowing deletion.
Error:
AssertionError:
View function mapping is overwriting an existing endpoint function
This means two routes are using the same endpoint function name.
For example:
@app.route("/admin")
def admin():
...and later:
@app.route("/admin")
def admin():
...Only define one function with the endpoint name:
admin
Remove duplicate admin routes or rename the function.
Check:
ADMIN_PASSWORDexists in Cloud Run.- The environment variable name is exactly:
ADMIN_PASSWORD
APP_FLASK_SECRET_KEYis also configured.- The Cloud Run service account has required permissions.
- The Cloud Run logs contain the actual traceback.
Check logs:
gcloud run services logs read partyphotos \
--region us-central1If the password is configured after deployment, make sure a new revision was created and traffic is directed to the latest revision.
Example:
No such object:
thumbnails/filename.jpg
This usually means:
- The original image exists.
- The thumbnail does not exist.
- Thumbnail generation failed.
- The image was uploaded before thumbnail generation was implemented.
Possible solutions:
- Delete old images and re-upload them.
- Generate missing thumbnails.
- Add a fallback to the original image.
The frontend can use:
onerror="this.onerror=null; this.src='{{ img.original }}';"Make sure your image uses:
class="w-full h-auto block"Do not use:
class="aspect-square"or:
class="h-full object-cover"if you want portrait and landscape proportions preserved.
For masonry layouts:
<div class="columns-2 gap-3 space-y-3">
<div class="break-inside-avoid">
<img
src="{{ img.thumbnail }}"
class="w-full h-auto block"
>
</div>
</div>The gallery should use:
src="{{ img.thumbnail }}"not:
src="{{ img.original }}"The full image should only be inserted when the modal opens.
This reduces initial data usage and improves gallery performance.
Check:
- The service account exists.
- The service account has Storage permissions.
- The IAM Credentials API is enabled.
- The service account has:
roles/iam.serviceAccountTokenCreator
- The service account email is correct.
- Cloud Run is running under the expected service account.
Check:
- The user successfully logged in.
- The session contains:
session["is_admin"] = True- The delete route checks:
if not session.get("is_admin"):- The route is using the correct storage object path.
- The form action points to the admin delete route.
The public videos page should not be used for admin deletion.
Use:
/admin/videos
for admin video management.
The bucket should eventually look like:
party28/
β
βββ images/
β βββ uuid_photo1.jpg
β βββ uuid_photo2.heic
β βββ uuid_photo3.png
β
βββ thumbnails/
β βββ uuid_photo1.jpg
β βββ uuid_photo2.jpg
β βββ uuid_photo3.jpg
β
βββ videos/
βββ uuid_video1.mp4
βββ uuid_video2.mov
This project may incur costs for:
- Google Cloud Storage storage
- Storage operations
- Data transfer
- Cloud Run usage
For a small party application, usage may remain very low, but monitor your Google Cloud billing dashboard.
Consider setting up a billing budget alert.
List images:
gcloud storage ls gs://YOUR_BUCKET_NAME/images/List thumbnails:
gcloud storage ls gs://YOUR_BUCKET_NAME/thumbnails/List videos:
gcloud storage ls gs://YOUR_BUCKET_NAME/videos/Delete a file:
gcloud storage rm gs://YOUR_BUCKET_NAME/images/FILENAMEBe careful when deleting files because Google Cloud Storage deletion is permanent unless versioning is enabled.
Before deploying:
[ ] requirements.txt exists
[ ] Dockerfile exists
[ ] .dockerignore exists
[ ] .gitignore exists
[ ] .env is not committed
[ ] service-account.json is not committed
[ ] BUCKET_NAME is correct
[ ] APP_FLASK_SECRET_KEY is configured
[ ] ADMIN_PASSWORD is configured
[ ] FLASK_DEBUG=false
[ ] Cloud Storage API is enabled
[ ] IAM Credentials API is enabled
[ ] Cloud Run service account has Storage permissions
[ ] Service account has Token Creator permissions
[ ] Bucket exists
[ ] Image uploads work
[ ] Direct camera capture works
[ ] Multiple image uploads work
[ ] Video uploads work
[ ] Signed URLs work
[ ] Thumbnail generation works
[ ] HEIC uploads work
[ ] Public image gallery works
[ ] Public video gallery works
[ ] Videos pause when scrolled off-screen
[ ] Admin login works
[ ] Admin image deletion works
[ ] Admin video deletion works
[ ] Admin logout works
[ ] JPG
[ ] PNG
[ ] HEIC
[ ] Portrait image
[ ] Landscape image
[ ] Multiple images
[ ] Direct camera photo
[ ] iPhone photo upload
[ ] Android photo upload
[ ] MP4
[ ] MOV
[ ] Large video
[ ] Video-only upload
[ ] Thumbnail loads
[ ] Original does not load immediately
[ ] Full image opens when clicked
[ ] Portrait proportions are preserved
[ ] Landscape proportions are preserved
[ ] Load More works
[ ] Image count is displayed
[ ] Video count is displayed
[ ] Off-screen videos pause
[ ] /admin login works
[ ] Incorrect password is rejected
[ ] Correct password opens dashboard
[ ] Images are displayed
[ ] Images can be deleted
[ ] /admin/videos opens admin video page
[ ] Videos are displayed
[ ] Videos can be deleted
[ ] Logout works
[ ] Public /images remains public
[ ] Public /videos remains public
βββββββββββββββββββββ
β β
β User β
β Browser β
β β
βββββββββββ¬ββββββββββ
β
βΌ
βββββββββΌβββββββββ
β β
β Cloud Run β
β Flask App β
β β
βββββββββ¬ββββββββββ
β
ββββββββββββββββ΄βββββββββββββββ
β β
β β
βββββββββΌβββββββββ ββββββββββΌβββββββββ
β β β β
β Google Cloud β β Signed Video β
β Storage β β Upload URL β
β β β β
βββββββββ¬ββββββββββ ββββββββββ¬βββββββββ
β β
β β
βββββββββββββΌββββββββββββ β
β β β β
βΌ βΌ βΌ βΌ
images/ thumbnails/ videos/ Browser β GCS
The Party Photos application is designed to be a lightweight media-sharing application using:
- Flask
- Google Cloud Run
- Google Cloud Storage
- Signed URLs
- Pillow
- HEIC support
- Background thumbnail generation
- Responsive frontend layouts
- Mobile camera capture
- Progressive image loading
- Automatic video pausing
- Password-protected administration
- Admin photo deletion
- Admin video deletion
The most important production rules are:
Keep large video uploads away from Flask whenever possible. Use signed URLs and upload directly to Google Cloud Storage.
Keep admin credentials and Flask session secrets outside the source code.
Keep public viewing routes separate from admin management routes.
This keeps the application faster, cheaper, safer, and easier to maintain.