snapshare 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ .git/
2
+ .github/
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ __pycache__/
7
+ tests/
8
+ uploads/
9
+ *.pyc
10
+ *.pyo
11
+ .env
12
+ .gitignore
13
+ .pre-commit-config.yaml
14
+ README.md
15
+ Dockerfile
16
+ .dockerignore
@@ -0,0 +1,16 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+
8
+ - package-ecosystem: github-actions
9
+ directory: /
10
+ schedule:
11
+ interval: weekly
12
+
13
+ - package-ecosystem: pre-commit
14
+ directory: /
15
+ schedule:
16
+ interval: weekly
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ lint-and-test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v7
13
+
14
+ - uses: actions/setup-python@v7
15
+ with:
16
+ python-version: "3.13"
17
+
18
+ - name: Install dependencies
19
+ run: pip install '.[dev]'
20
+
21
+ - name: Lint
22
+ run: ruff check .
23
+
24
+ - name: Format check
25
+ run: ruff format --check .
26
+
27
+ - name: Test
28
+ run: pytest
@@ -0,0 +1,44 @@
1
+ name: Deploy
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ lint-and-test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v7
13
+
14
+ - uses: actions/setup-python@v7
15
+ with:
16
+ python-version: "3.13"
17
+
18
+ - name: Install dependencies
19
+ run: pip install '.[dev]'
20
+
21
+ - name: Lint
22
+ run: ruff check .
23
+
24
+ - name: Format check
25
+ run: ruff format --check .
26
+
27
+ - name: Test
28
+ run: pytest
29
+
30
+ deploy:
31
+ needs: lint-and-test
32
+ runs-on: ubuntu-latest
33
+ concurrency:
34
+ group: deploy-fly
35
+ cancel-in-progress: false
36
+ steps:
37
+ - uses: actions/checkout@v7
38
+
39
+ - uses: superfly/flyctl-actions/setup-flyctl@master
40
+
41
+ - name: Deploy to Fly.io
42
+ run: flyctl deploy --remote-only
43
+ env:
44
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ uploads/
3
+ __pycache__/
4
+ *.pyc
5
+ *.egg-info/
6
+ build/
7
+ dist/
8
+ .env
@@ -0,0 +1,7 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.16.6
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
@@ -0,0 +1,14 @@
1
+ FROM python:3.13-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY pyproject.toml .
6
+ COPY src/ src/
7
+ # .git isn't in the build context, so setuptools-scm can't derive the version.
8
+ # The container doesn't need a real version, so pin it to a sentinel.
9
+ ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_SNAPSHARE=0.0.0
10
+ RUN pip install --no-cache-dir .
11
+
12
+ EXPOSE 8080
13
+
14
+ CMD ["gunicorn", "--workers", "2", "--timeout", "60", "--bind", "0.0.0.0:8080", "--access-logfile", "-", "snapshare.app:app"]
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: snapshare
3
+ Version: 0.2.2
4
+ Summary: Lightweight photo-sharing web app for group events
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: boto3==1.43.89
8
+ Requires-Dist: flask==3.1.3
9
+ Requires-Dist: gunicorn==26.2.0
10
+ Requires-Dist: pillow==12.3.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: build==1.6.0; extra == "dev"
13
+ Requires-Dist: moto[s3]==5.2.3; extra == "dev"
14
+ Requires-Dist: pre-commit==4.6.2; extra == "dev"
15
+ Requires-Dist: pytest==9.1.1; extra == "dev"
16
+ Requires-Dist: ruff==0.16.6; extra == "dev"
17
+ Requires-Dist: twine==7.0.0; extra == "dev"
18
+
19
+ # SnapShare
20
+
21
+ A lightweight photo-sharing web app for group events. Guests scan a link (or QR code), upload photos from their phone, and instantly see everyone else's shots in a shared gallery.
22
+
23
+ ## Prerequisites
24
+
25
+ - Python 3.13+
26
+
27
+ ## Setup
28
+
29
+ ```bash
30
+ python3 -m venv .venv
31
+ source .venv/bin/activate
32
+ pip install '.[dev]' # installs app + dev deps from pyproject.toml
33
+ pre-commit install # wire up git hooks
34
+ ```
35
+
36
+ ## Running locally
37
+
38
+ ```bash
39
+ python -m snapshare.app
40
+ ```
41
+
42
+ Open [http://localhost:5000](http://localhost:5000).
43
+
44
+ Uploaded photos are saved to an `uploads/` directory next to the module (created automatically, git-ignored).
45
+
46
+ ## Project structure
47
+
48
+ ```
49
+ snapshare/
50
+ ├── pyproject.toml # Project metadata, dependencies, tool config
51
+ ├── src/
52
+ │ └── snapshare/
53
+ │ ├── __init__.py
54
+ │ ├── app.py # Flask app — routes and upload logic
55
+ │ └── templates/
56
+ │ ├── index.html # Upload form
57
+ │ └── gallery.html # Shared photo gallery
58
+ └── tests/ # Pytest suite
59
+ ```
60
+
61
+ ## Linting & formatting
62
+
63
+ ```bash
64
+ ruff check . # lint
65
+ ruff check --fix . # lint + auto-fix
66
+ ruff format . # format
67
+ ```
68
+
69
+ ## Testing
70
+
71
+ ```bash
72
+ pytest
73
+ ```
74
+
75
+ ## Publishing to PyPI
76
+
77
+ Version is derived from the nearest `v*` git tag via `setuptools-scm`. Building
78
+ from an untagged commit produces a dev version (e.g. `0.2.2.dev1+g1234abc`).
79
+ To cut a real release from a tagged commit:
80
+
81
+ ```bash
82
+ git checkout v0.2.1 # or whichever tag
83
+ rm -rf dist/ build/ # clean any previous build output
84
+ python -m build # creates dist/snapshare-0.2.1-*.whl and .tar.gz
85
+ twine check dist/* # validate metadata + long description
86
+ twine upload dist/* # uploads to PyPI (needs API token, see below)
87
+ ```
88
+
89
+ **PyPI credentials:** create an API token at
90
+ [pypi.org/manage/account/token/](https://pypi.org/manage/account/token/)
91
+ scoped to the `snapshare` project. `twine` reads it from `~/.pypirc` or the
92
+ `TWINE_USERNAME=__token__` + `TWINE_PASSWORD=pypi-...` env vars.
93
+
94
+ ## Roadmap
95
+
96
+ - [ ] Event/album scoping with shareable links
97
+ - [ ] QR code generation for events
98
+ - [ ] Cloud storage backend (S3 / GCS)
99
+ - [ ] Mobile-optimised gallery
@@ -0,0 +1,81 @@
1
+ # SnapShare
2
+
3
+ A lightweight photo-sharing web app for group events. Guests scan a link (or QR code), upload photos from their phone, and instantly see everyone else's shots in a shared gallery.
4
+
5
+ ## Prerequisites
6
+
7
+ - Python 3.13+
8
+
9
+ ## Setup
10
+
11
+ ```bash
12
+ python3 -m venv .venv
13
+ source .venv/bin/activate
14
+ pip install '.[dev]' # installs app + dev deps from pyproject.toml
15
+ pre-commit install # wire up git hooks
16
+ ```
17
+
18
+ ## Running locally
19
+
20
+ ```bash
21
+ python -m snapshare.app
22
+ ```
23
+
24
+ Open [http://localhost:5000](http://localhost:5000).
25
+
26
+ Uploaded photos are saved to an `uploads/` directory next to the module (created automatically, git-ignored).
27
+
28
+ ## Project structure
29
+
30
+ ```
31
+ snapshare/
32
+ ├── pyproject.toml # Project metadata, dependencies, tool config
33
+ ├── src/
34
+ │ └── snapshare/
35
+ │ ├── __init__.py
36
+ │ ├── app.py # Flask app — routes and upload logic
37
+ │ └── templates/
38
+ │ ├── index.html # Upload form
39
+ │ └── gallery.html # Shared photo gallery
40
+ └── tests/ # Pytest suite
41
+ ```
42
+
43
+ ## Linting & formatting
44
+
45
+ ```bash
46
+ ruff check . # lint
47
+ ruff check --fix . # lint + auto-fix
48
+ ruff format . # format
49
+ ```
50
+
51
+ ## Testing
52
+
53
+ ```bash
54
+ pytest
55
+ ```
56
+
57
+ ## Publishing to PyPI
58
+
59
+ Version is derived from the nearest `v*` git tag via `setuptools-scm`. Building
60
+ from an untagged commit produces a dev version (e.g. `0.2.2.dev1+g1234abc`).
61
+ To cut a real release from a tagged commit:
62
+
63
+ ```bash
64
+ git checkout v0.2.1 # or whichever tag
65
+ rm -rf dist/ build/ # clean any previous build output
66
+ python -m build # creates dist/snapshare-0.2.1-*.whl and .tar.gz
67
+ twine check dist/* # validate metadata + long description
68
+ twine upload dist/* # uploads to PyPI (needs API token, see below)
69
+ ```
70
+
71
+ **PyPI credentials:** create an API token at
72
+ [pypi.org/manage/account/token/](https://pypi.org/manage/account/token/)
73
+ scoped to the `snapshare` project. `twine` reads it from `~/.pypirc` or the
74
+ `TWINE_USERNAME=__token__` + `TWINE_PASSWORD=pypi-...` env vars.
75
+
76
+ ## Roadmap
77
+
78
+ - [ ] Event/album scoping with shareable links
79
+ - [ ] QR code generation for events
80
+ - [ ] Cloud storage backend (S3 / GCS)
81
+ - [ ] Mobile-optimised gallery
@@ -0,0 +1,21 @@
1
+ # fly.toml app configuration file generated for snapshare on 2026-09-06T21:35:18-05:00
2
+ #
3
+ # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4
+ #
5
+
6
+ app = 'snapshare'
7
+ primary_region = 'iad'
8
+
9
+ [build]
10
+ dockerfile = 'Dockerfile'
11
+
12
+ [http_service]
13
+ internal_port = 8080
14
+ force_https = true
15
+ auto_stop_machines = 'stop'
16
+ auto_start_machines = true
17
+ min_machines_running = 0
18
+ processes = ['app']
19
+
20
+ [[vm]]
21
+ size = 'shared-cpu-1x'
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "setuptools-scm>=8"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "snapshare"
7
+ dynamic = ["version"]
8
+ description = "Lightweight photo-sharing web app for group events"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "boto3==1.43.89",
13
+ "flask==3.1.3",
14
+ "gunicorn==26.2.0",
15
+ "pillow==12.3.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "build==1.6.0",
21
+ "moto[s3]==5.2.3",
22
+ "pre-commit==4.6.2",
23
+ "pytest==9.1.1",
24
+ "ruff==0.16.6",
25
+ "twine==7.0.0",
26
+ ]
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [tool.setuptools.package-data]
32
+ snapshare = ["templates/*.html"]
33
+
34
+ [tool.setuptools_scm]
35
+ # Version derived from the nearest `v*` git tag.
36
+
37
+ [tool.ruff]
38
+ target-version = "py313"
39
+ line-length = 100
40
+
41
+ [tool.ruff.lint]
42
+ select = [
43
+ "E", # pycodestyle errors
44
+ "W", # pycodestyle warnings
45
+ "F", # pyflakes
46
+ "I", # isort
47
+ "UP", # pyupgrade
48
+ ]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,78 @@
1
+ import io
2
+ import os
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ from flask import Flask, abort, redirect, render_template, request, url_for
7
+ from PIL import Image
8
+
9
+ from snapshare import storage
10
+
11
+ ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
12
+ MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 20 MB
13
+ ALLOWED_IPS = {ip.strip() for ip in os.environ.get("ALLOWED_IPS", "").split(",") if ip.strip()}
14
+
15
+ app = Flask(__name__)
16
+ app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
17
+
18
+
19
+ @app.before_request
20
+ def restrict_ip():
21
+ if not ALLOWED_IPS:
22
+ return
23
+ client_ip = request.headers.get("Fly-Client-IP", request.remote_addr)
24
+ if client_ip not in ALLOWED_IPS:
25
+ abort(403)
26
+
27
+
28
+ def allowed_file(filename: str) -> bool:
29
+ return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
30
+
31
+
32
+ @app.route("/")
33
+ def index():
34
+ return render_template("index.html")
35
+
36
+
37
+ @app.route("/upload", methods=["POST"])
38
+ def upload():
39
+ file = request.files.get("photo")
40
+ if not file or file.filename == "":
41
+ return render_template("index.html", error="No file selected.")
42
+
43
+ if not allowed_file(file.filename):
44
+ return render_template("index.html", error="Only image files are allowed.")
45
+
46
+ suffix = Path(file.filename).suffix.lower()
47
+ key = f"{uuid.uuid4().hex}{suffix}"
48
+ raw = io.BytesIO(file.read())
49
+
50
+ # Normalise orientation from EXIF so browsers display it correctly
51
+ try:
52
+ with Image.open(raw) as img:
53
+ img = img.convert("RGB")
54
+ exif = img.getexif()
55
+ orientation = exif.get(274) # 274 = Orientation tag
56
+ rotations = {3: 180, 6: 270, 8: 90}
57
+ if orientation in rotations:
58
+ img = img.rotate(rotations[orientation], expand=True)
59
+ out = io.BytesIO()
60
+ img.save(out, "JPEG", quality=90)
61
+ out.seek(0)
62
+ key = f"{uuid.uuid4().hex}.jpg"
63
+ storage.save(key, out, content_type="image/jpeg")
64
+ except Exception:
65
+ raw.seek(0)
66
+ storage.save(key, raw)
67
+
68
+ return redirect(url_for("gallery"))
69
+
70
+
71
+ @app.route("/gallery")
72
+ def gallery():
73
+ photos = [storage.photo_url(key) for key in storage.list_keys()]
74
+ return render_template("gallery.html", photos=photos)
75
+
76
+
77
+ if __name__ == "__main__":
78
+ app.run(debug=True, port=5000)
@@ -0,0 +1,45 @@
1
+ """S3-compatible object storage for photos.
2
+
3
+ Backed by Tigris in production. Fly.io's `fly storage create` injects
4
+ AWS_ENDPOINT_URL_S3, BUCKET_NAME, and credentials as app secrets, so no
5
+ explicit configuration is needed. Tests use moto.
6
+
7
+ The bucket is private (Tigris' default), so gallery photos are served via
8
+ short-lived presigned GET URLs generated per gallery view. See
9
+ `photo_url()`.
10
+ """
11
+
12
+ import os
13
+ from typing import BinaryIO
14
+
15
+ import boto3
16
+
17
+ PRESIGNED_URL_TTL_SECONDS = 3600 # 1 hour
18
+
19
+
20
+ def _client():
21
+ # Fresh client per call so moto's mock_aws intercepts in tests.
22
+ return boto3.client("s3")
23
+
24
+
25
+ def _bucket() -> str:
26
+ return os.environ.get("BUCKET_NAME", "")
27
+
28
+
29
+ def save(key: str, body: BinaryIO, content_type: str = "application/octet-stream") -> None:
30
+ _client().put_object(Bucket=_bucket(), Key=key, Body=body, ContentType=content_type)
31
+
32
+
33
+ def list_keys() -> list[str]:
34
+ resp = _client().list_objects_v2(Bucket=_bucket())
35
+ objects = resp.get("Contents", [])
36
+ objects.sort(key=lambda o: o["LastModified"], reverse=True)
37
+ return [o["Key"] for o in objects]
38
+
39
+
40
+ def photo_url(key: str) -> str:
41
+ return _client().generate_presigned_url(
42
+ "get_object",
43
+ Params={"Bucket": _bucket(), "Key": key},
44
+ ExpiresIn=PRESIGNED_URL_TTL_SECONDS,
45
+ )
@@ -0,0 +1,145 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>SnapShare — Gallery</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ body {
11
+ font-family: system-ui, sans-serif;
12
+ background: #f5f5f5;
13
+ min-height: 100vh;
14
+ padding: 1.5rem;
15
+ }
16
+
17
+ header {
18
+ display: flex;
19
+ align-items: baseline;
20
+ gap: 1rem;
21
+ margin-bottom: 1.5rem;
22
+ max-width: 960px;
23
+ margin-left: auto;
24
+ margin-right: auto;
25
+ }
26
+
27
+ header h1 { font-size: 1.6rem; }
28
+
29
+ .upload-link {
30
+ margin-left: auto;
31
+ color: #6366f1;
32
+ text-decoration: none;
33
+ font-size: .9rem;
34
+ font-weight: 600;
35
+ }
36
+ .upload-link:hover { text-decoration: underline; }
37
+
38
+ .count { color: #888; font-size: .9rem; }
39
+
40
+ .grid {
41
+ display: grid;
42
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
43
+ gap: .75rem;
44
+ max-width: 960px;
45
+ margin: 0 auto;
46
+ }
47
+
48
+ .grid a {
49
+ display: block;
50
+ border-radius: .6rem;
51
+ overflow: hidden;
52
+ background: #ddd;
53
+ aspect-ratio: 1;
54
+ }
55
+
56
+ .grid img {
57
+ width: 100%;
58
+ height: 100%;
59
+ object-fit: cover;
60
+ display: block;
61
+ transition: transform .2s;
62
+ }
63
+ .grid a:hover img { transform: scale(1.04); }
64
+
65
+ .empty {
66
+ text-align: center;
67
+ color: #aaa;
68
+ margin-top: 4rem;
69
+ font-size: 1.1rem;
70
+ }
71
+
72
+ /* Lightbox */
73
+ #lightbox {
74
+ display: none;
75
+ position: fixed;
76
+ inset: 0;
77
+ background: rgba(0,0,0,.85);
78
+ align-items: center;
79
+ justify-content: center;
80
+ z-index: 100;
81
+ padding: 1rem;
82
+ }
83
+ #lightbox.open { display: flex; }
84
+ #lightbox img {
85
+ max-width: 100%;
86
+ max-height: 90vh;
87
+ border-radius: .5rem;
88
+ box-shadow: 0 8px 40px rgba(0,0,0,.5);
89
+ }
90
+ #lightbox .close {
91
+ position: absolute;
92
+ top: 1rem; right: 1.25rem;
93
+ color: #fff;
94
+ font-size: 2rem;
95
+ cursor: pointer;
96
+ line-height: 1;
97
+ }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <header>
102
+ <h1>SnapShare</h1>
103
+ {% if photos %}
104
+ <span class="count">{{ photos|length }} photo{{ 's' if photos|length != 1 }}</span>
105
+ {% endif %}
106
+ <a href="/" class="upload-link">+ Add a photo</a>
107
+ </header>
108
+
109
+ {% if photos %}
110
+ <div class="grid">
111
+ {% for photo in photos %}
112
+ <a href="#" data-src="{{ photo }}" class="thumb">
113
+ <img src="{{ photo }}" alt="shared photo" loading="lazy">
114
+ </a>
115
+ {% endfor %}
116
+ </div>
117
+ {% else %}
118
+ <p class="empty">No photos yet — be the first to share one!</p>
119
+ {% endif %}
120
+
121
+ <div id="lightbox">
122
+ <span class="close" id="lbClose">&times;</span>
123
+ <img id="lbImg" src="" alt="full size photo">
124
+ </div>
125
+
126
+ <script>
127
+ const lightbox = document.getElementById('lightbox');
128
+ const lbImg = document.getElementById('lbImg');
129
+
130
+ document.querySelectorAll('.thumb').forEach(a => {
131
+ a.addEventListener('click', e => {
132
+ e.preventDefault();
133
+ lbImg.src = a.dataset.src;
134
+ lightbox.classList.add('open');
135
+ });
136
+ });
137
+
138
+ document.getElementById('lbClose').addEventListener('click', close);
139
+ lightbox.addEventListener('click', e => { if (e.target === lightbox) close(); });
140
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
141
+
142
+ function close() { lightbox.classList.remove('open'); lbImg.src = ''; }
143
+ </script>
144
+ </body>
145
+ </html>
@@ -0,0 +1,176 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>SnapShare — Share Your Photos</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ body {
11
+ font-family: system-ui, sans-serif;
12
+ background: #f5f5f5;
13
+ min-height: 100vh;
14
+ display: flex;
15
+ flex-direction: column;
16
+ align-items: center;
17
+ justify-content: center;
18
+ padding: 1.5rem;
19
+ }
20
+
21
+ .card {
22
+ background: #fff;
23
+ border-radius: 1rem;
24
+ box-shadow: 0 4px 24px rgba(0,0,0,.08);
25
+ padding: 2.5rem 2rem;
26
+ width: 100%;
27
+ max-width: 420px;
28
+ }
29
+
30
+ h1 { font-size: 1.6rem; margin-bottom: .25rem; }
31
+ .subtitle { color: #666; font-size: .95rem; margin-bottom: 2rem; }
32
+
33
+ .drop-zone {
34
+ border: 2px dashed #ccc;
35
+ border-radius: .75rem;
36
+ padding: 2.5rem 1rem;
37
+ text-align: center;
38
+ cursor: pointer;
39
+ transition: border-color .2s, background .2s;
40
+ position: relative;
41
+ }
42
+ .drop-zone:hover, .drop-zone.dragover {
43
+ border-color: #6366f1;
44
+ background: #f0f0ff;
45
+ }
46
+ .drop-zone input[type="file"] {
47
+ position: absolute;
48
+ inset: 0;
49
+ opacity: 0;
50
+ cursor: pointer;
51
+ width: 100%;
52
+ height: 100%;
53
+ }
54
+ .drop-zone .icon { font-size: 2.5rem; display: block; margin-bottom: .5rem; }
55
+ .drop-zone .hint { color: #888; font-size: .85rem; margin-top: .25rem; }
56
+
57
+ #preview {
58
+ margin-top: 1rem;
59
+ display: none;
60
+ border-radius: .5rem;
61
+ overflow: hidden;
62
+ }
63
+ #preview img {
64
+ width: 100%;
65
+ max-height: 220px;
66
+ object-fit: cover;
67
+ display: block;
68
+ }
69
+ #preview .filename {
70
+ background: #f5f5f5;
71
+ padding: .4rem .75rem;
72
+ font-size: .8rem;
73
+ color: #555;
74
+ word-break: break-all;
75
+ }
76
+
77
+ .error {
78
+ margin-top: 1rem;
79
+ padding: .75rem 1rem;
80
+ background: #fff0f0;
81
+ border-left: 3px solid #e55;
82
+ color: #c00;
83
+ border-radius: .25rem;
84
+ font-size: .9rem;
85
+ }
86
+
87
+ button[type="submit"] {
88
+ margin-top: 1.25rem;
89
+ width: 100%;
90
+ padding: .85rem;
91
+ background: #6366f1;
92
+ color: #fff;
93
+ border: none;
94
+ border-radius: .75rem;
95
+ font-size: 1rem;
96
+ font-weight: 600;
97
+ cursor: pointer;
98
+ transition: background .2s;
99
+ }
100
+ button[type="submit"]:hover { background: #4f51d8; }
101
+ button[type="submit"]:disabled { background: #a5b4fc; cursor: not-allowed; }
102
+
103
+ .gallery-link {
104
+ display: block;
105
+ text-align: center;
106
+ margin-top: 1.25rem;
107
+ color: #6366f1;
108
+ text-decoration: none;
109
+ font-size: .9rem;
110
+ }
111
+ .gallery-link:hover { text-decoration: underline; }
112
+ </style>
113
+ </head>
114
+ <body>
115
+ <div class="card">
116
+ <h1>SnapShare</h1>
117
+ <p class="subtitle">Upload your photos to the shared album.</p>
118
+
119
+ <form method="post" action="/upload" enctype="multipart/form-data" id="uploadForm">
120
+ <div class="drop-zone" id="dropZone">
121
+ <span class="icon">📷</span>
122
+ <strong>Tap to choose a photo</strong><br>
123
+ <span class="hint">or drag &amp; drop here</span>
124
+ <input type="file" name="photo" accept="image/*" id="fileInput" required>
125
+ </div>
126
+
127
+ <div id="preview">
128
+ <img id="previewImg" src="" alt="preview">
129
+ <div class="filename" id="previewName"></div>
130
+ </div>
131
+
132
+ {% if error %}
133
+ <div class="error">{{ error }}</div>
134
+ {% endif %}
135
+
136
+ <button type="submit" id="submitBtn" disabled>Upload Photo</button>
137
+ </form>
138
+
139
+ <a href="/gallery" class="gallery-link">View shared gallery →</a>
140
+ </div>
141
+
142
+ <script>
143
+ const input = document.getElementById('fileInput');
144
+ const preview = document.getElementById('preview');
145
+ const previewImg = document.getElementById('previewImg');
146
+ const previewName = document.getElementById('previewName');
147
+ const submitBtn = document.getElementById('submitBtn');
148
+ const dropZone = document.getElementById('dropZone');
149
+
150
+ input.addEventListener('change', () => handleFile(input.files[0]));
151
+
152
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
153
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
154
+ dropZone.addEventListener('drop', e => {
155
+ e.preventDefault();
156
+ dropZone.classList.remove('dragover');
157
+ const file = e.dataTransfer.files[0];
158
+ if (file) { input.files = e.dataTransfer.files; handleFile(file); }
159
+ });
160
+
161
+ function handleFile(file) {
162
+ if (!file) return;
163
+ const url = URL.createObjectURL(file);
164
+ previewImg.src = url;
165
+ previewName.textContent = file.name;
166
+ preview.style.display = 'block';
167
+ submitBtn.disabled = false;
168
+ }
169
+
170
+ document.getElementById('uploadForm').addEventListener('submit', () => {
171
+ submitBtn.disabled = true;
172
+ submitBtn.textContent = 'Uploading…';
173
+ });
174
+ </script>
175
+ </body>
176
+ </html>
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: snapshare
3
+ Version: 0.2.2
4
+ Summary: Lightweight photo-sharing web app for group events
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: boto3==1.43.89
8
+ Requires-Dist: flask==3.1.3
9
+ Requires-Dist: gunicorn==26.2.0
10
+ Requires-Dist: pillow==12.3.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: build==1.6.0; extra == "dev"
13
+ Requires-Dist: moto[s3]==5.2.3; extra == "dev"
14
+ Requires-Dist: pre-commit==4.6.2; extra == "dev"
15
+ Requires-Dist: pytest==9.1.1; extra == "dev"
16
+ Requires-Dist: ruff==0.16.6; extra == "dev"
17
+ Requires-Dist: twine==7.0.0; extra == "dev"
18
+
19
+ # SnapShare
20
+
21
+ A lightweight photo-sharing web app for group events. Guests scan a link (or QR code), upload photos from their phone, and instantly see everyone else's shots in a shared gallery.
22
+
23
+ ## Prerequisites
24
+
25
+ - Python 3.13+
26
+
27
+ ## Setup
28
+
29
+ ```bash
30
+ python3 -m venv .venv
31
+ source .venv/bin/activate
32
+ pip install '.[dev]' # installs app + dev deps from pyproject.toml
33
+ pre-commit install # wire up git hooks
34
+ ```
35
+
36
+ ## Running locally
37
+
38
+ ```bash
39
+ python -m snapshare.app
40
+ ```
41
+
42
+ Open [http://localhost:5000](http://localhost:5000).
43
+
44
+ Uploaded photos are saved to an `uploads/` directory next to the module (created automatically, git-ignored).
45
+
46
+ ## Project structure
47
+
48
+ ```
49
+ snapshare/
50
+ ├── pyproject.toml # Project metadata, dependencies, tool config
51
+ ├── src/
52
+ │ └── snapshare/
53
+ │ ├── __init__.py
54
+ │ ├── app.py # Flask app — routes and upload logic
55
+ │ └── templates/
56
+ │ ├── index.html # Upload form
57
+ │ └── gallery.html # Shared photo gallery
58
+ └── tests/ # Pytest suite
59
+ ```
60
+
61
+ ## Linting & formatting
62
+
63
+ ```bash
64
+ ruff check . # lint
65
+ ruff check --fix . # lint + auto-fix
66
+ ruff format . # format
67
+ ```
68
+
69
+ ## Testing
70
+
71
+ ```bash
72
+ pytest
73
+ ```
74
+
75
+ ## Publishing to PyPI
76
+
77
+ Version is derived from the nearest `v*` git tag via `setuptools-scm`. Building
78
+ from an untagged commit produces a dev version (e.g. `0.2.2.dev1+g1234abc`).
79
+ To cut a real release from a tagged commit:
80
+
81
+ ```bash
82
+ git checkout v0.2.1 # or whichever tag
83
+ rm -rf dist/ build/ # clean any previous build output
84
+ python -m build # creates dist/snapshare-0.2.1-*.whl and .tar.gz
85
+ twine check dist/* # validate metadata + long description
86
+ twine upload dist/* # uploads to PyPI (needs API token, see below)
87
+ ```
88
+
89
+ **PyPI credentials:** create an API token at
90
+ [pypi.org/manage/account/token/](https://pypi.org/manage/account/token/)
91
+ scoped to the `snapshare` project. `twine` reads it from `~/.pypirc` or the
92
+ `TWINE_USERNAME=__token__` + `TWINE_PASSWORD=pypi-...` env vars.
93
+
94
+ ## Roadmap
95
+
96
+ - [ ] Event/album scoping with shareable links
97
+ - [ ] QR code generation for events
98
+ - [ ] Cloud storage backend (S3 / GCS)
99
+ - [ ] Mobile-optimised gallery
@@ -0,0 +1,26 @@
1
+ .dockerignore
2
+ .gitignore
3
+ .pre-commit-config.yaml
4
+ Dockerfile
5
+ README.md
6
+ fly.toml
7
+ pyproject.toml
8
+ .github/dependabot.yml
9
+ .github/workflows/ci.yml
10
+ .github/workflows/deploy.yml
11
+ src/snapshare/__init__.py
12
+ src/snapshare/app.py
13
+ src/snapshare/storage.py
14
+ src/snapshare.egg-info/PKG-INFO
15
+ src/snapshare.egg-info/SOURCES.txt
16
+ src/snapshare.egg-info/dependency_links.txt
17
+ src/snapshare.egg-info/requires.txt
18
+ src/snapshare.egg-info/scm_file_list.json
19
+ src/snapshare.egg-info/scm_version.json
20
+ src/snapshare.egg-info/top_level.txt
21
+ src/snapshare/templates/gallery.html
22
+ src/snapshare/templates/index.html
23
+ tests/conftest.py
24
+ tests/test_gallery.py
25
+ tests/test_ip_restriction.py
26
+ tests/test_upload.py
@@ -0,0 +1,12 @@
1
+ boto3==1.43.89
2
+ flask==3.1.3
3
+ gunicorn==26.2.0
4
+ pillow==12.3.0
5
+
6
+ [dev]
7
+ build==1.6.0
8
+ moto[s3]==5.2.3
9
+ pre-commit==4.6.2
10
+ pytest==9.1.1
11
+ ruff==0.16.6
12
+ twine==7.0.0
@@ -0,0 +1,23 @@
1
+ {
2
+ "files": [
3
+ ".dockerignore",
4
+ ".github/dependabot.yml",
5
+ ".github/workflows/ci.yml",
6
+ ".github/workflows/deploy.yml",
7
+ ".gitignore",
8
+ ".pre-commit-config.yaml",
9
+ "Dockerfile",
10
+ "README.md",
11
+ "fly.toml",
12
+ "pyproject.toml",
13
+ "src/snapshare/__init__.py",
14
+ "src/snapshare/app.py",
15
+ "src/snapshare/storage.py",
16
+ "src/snapshare/templates/gallery.html",
17
+ "src/snapshare/templates/index.html",
18
+ "tests/conftest.py",
19
+ "tests/test_gallery.py",
20
+ "tests/test_ip_restriction.py",
21
+ "tests/test_upload.py"
22
+ ]
23
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.2.2",
3
+ "distance": 0,
4
+ "node": "ga2b6f52c45e4f6ac377d50927e5af95996fc9ab1",
5
+ "dirty": false,
6
+ "branch": "master",
7
+ "node_date": "2026-09-07"
8
+ }
@@ -0,0 +1 @@
1
+ snapshare
@@ -0,0 +1,39 @@
1
+ import io
2
+
3
+ import boto3
4
+ import pytest
5
+ from moto import mock_aws
6
+ from PIL import Image
7
+
8
+ from snapshare.app import app as flask_app
9
+
10
+ TEST_BUCKET = "test-bucket"
11
+
12
+
13
+ @pytest.fixture()
14
+ def app(monkeypatch):
15
+ """App fixture with moto-backed S3 in place of real Tigris."""
16
+ monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
17
+ monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
18
+ monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
19
+ monkeypatch.setenv("BUCKET_NAME", TEST_BUCKET)
20
+ monkeypatch.delenv("AWS_ENDPOINT_URL_S3", raising=False)
21
+ monkeypatch.delenv("TIGRIS_PUBLIC_URL", raising=False)
22
+
23
+ with mock_aws():
24
+ boto3.client("s3", region_name="us-east-1").create_bucket(Bucket=TEST_BUCKET)
25
+ flask_app.config["TESTING"] = True
26
+ yield flask_app
27
+
28
+
29
+ @pytest.fixture()
30
+ def client(app):
31
+ return app.test_client()
32
+
33
+
34
+ def make_image_bytes(fmt="JPEG", size=(100, 100), color=(255, 0, 0)) -> bytes:
35
+ """Return raw bytes of a minimal valid image."""
36
+ buf = io.BytesIO()
37
+ Image.new("RGB", size, color).save(buf, format=fmt)
38
+ buf.seek(0)
39
+ return buf.read()
@@ -0,0 +1,42 @@
1
+ import io
2
+ from http import HTTPStatus
3
+
4
+ import boto3
5
+ from conftest import TEST_BUCKET, make_image_bytes
6
+
7
+
8
+ class TestGalleryRoute:
9
+ def test_empty_gallery_renders(self, client):
10
+ resp = client.get("/gallery")
11
+ assert resp.status_code == HTTPStatus.OK
12
+ assert b"No photos yet" in resp.data
13
+
14
+ def test_gallery_shows_uploaded_photo(self, client):
15
+ data = {"photo": (io.BytesIO(make_image_bytes("JPEG")), "shot.jpg")}
16
+ client.post("/upload", data=data, content_type="multipart/form-data")
17
+
18
+ resp = client.get("/gallery")
19
+ assert resp.status_code == HTTPStatus.OK
20
+ assert b"No photos yet" not in resp.data
21
+ assert TEST_BUCKET.encode() in resp.data
22
+
23
+ def test_gallery_photo_count(self, client):
24
+ for i in range(3):
25
+ data = {"photo": (io.BytesIO(make_image_bytes("JPEG")), f"photo{i}.jpg")}
26
+ client.post("/upload", data=data, content_type="multipart/form-data")
27
+
28
+ resp = client.get("/gallery")
29
+ assert resp.data.count(b'class="thumb"') == 3
30
+
31
+ def test_gallery_links_are_presigned(self, client):
32
+ data = {"photo": (io.BytesIO(make_image_bytes("JPEG")), "test.jpg")}
33
+ client.post("/upload", data=data, content_type="multipart/form-data")
34
+
35
+ gallery_resp = client.get("/gallery")
36
+ # Presigned URLs carry a Signature= query param (SigV4: X-Amz-Signature=,
37
+ # SigV2 as emitted by moto: Signature=). Substring match works for both.
38
+ assert b"Signature=" in gallery_resp.data
39
+
40
+ s3 = boto3.client("s3", region_name="us-east-1")
41
+ objects = s3.list_objects_v2(Bucket=TEST_BUCKET).get("Contents", [])
42
+ assert len(objects) == 1
@@ -0,0 +1,44 @@
1
+ from http import HTTPStatus
2
+
3
+
4
+ class TestIPRestriction:
5
+ def test_no_restriction_when_allowlist_empty(self, client):
6
+ resp = client.get("/")
7
+ assert resp.status_code == HTTPStatus.OK
8
+
9
+ def test_remote_addr_in_allowlist_passes(self, client, monkeypatch):
10
+ import snapshare.app as app_module
11
+
12
+ monkeypatch.setattr(app_module, "ALLOWED_IPS", {"127.0.0.1"})
13
+ resp = client.get("/")
14
+ assert resp.status_code == HTTPStatus.OK
15
+
16
+ def test_remote_addr_not_in_allowlist_returns_403(self, client, monkeypatch):
17
+ import snapshare.app as app_module
18
+
19
+ monkeypatch.setattr(app_module, "ALLOWED_IPS", {"1.2.3.4"})
20
+ resp = client.get("/")
21
+ assert resp.status_code == HTTPStatus.FORBIDDEN
22
+
23
+ def test_fly_client_ip_header_allowed(self, client, monkeypatch):
24
+ import snapshare.app as app_module
25
+
26
+ monkeypatch.setattr(app_module, "ALLOWED_IPS", {"5.6.7.8"})
27
+ resp = client.get("/", headers={"Fly-Client-IP": "5.6.7.8"})
28
+ assert resp.status_code == HTTPStatus.OK
29
+
30
+ def test_fly_client_ip_header_disallowed(self, client, monkeypatch):
31
+ import snapshare.app as app_module
32
+
33
+ monkeypatch.setattr(app_module, "ALLOWED_IPS", {"5.6.7.8"})
34
+ resp = client.get("/", headers={"Fly-Client-IP": "9.9.9.9"})
35
+ assert resp.status_code == HTTPStatus.FORBIDDEN
36
+
37
+ def test_fly_client_ip_overrides_remote_addr(self, client, monkeypatch):
38
+ import snapshare.app as app_module
39
+
40
+ # remote_addr=127.0.0.1 is in the allowlist, but the Fly-Client-IP
41
+ # header takes precedence and is not — expect 403.
42
+ monkeypatch.setattr(app_module, "ALLOWED_IPS", {"127.0.0.1"})
43
+ resp = client.get("/", headers={"Fly-Client-IP": "9.9.9.9"})
44
+ assert resp.status_code == HTTPStatus.FORBIDDEN
@@ -0,0 +1,49 @@
1
+ import io
2
+ from http import HTTPStatus
3
+
4
+ import boto3
5
+ from conftest import TEST_BUCKET, make_image_bytes
6
+
7
+
8
+ class TestUploadRoute:
9
+ def test_no_file_returns_error(self, client):
10
+ resp = client.post("/upload", data={})
11
+ assert resp.status_code == HTTPStatus.OK
12
+ assert b"No file selected" in resp.data
13
+
14
+ def test_empty_filename_returns_error(self, client):
15
+ data = {"photo": (io.BytesIO(b""), "")}
16
+ resp = client.post("/upload", data=data, content_type="multipart/form-data")
17
+ assert resp.status_code == HTTPStatus.OK
18
+ assert b"No file selected" in resp.data
19
+
20
+ def test_disallowed_extension_returns_error(self, client):
21
+ data = {"photo": (io.BytesIO(b"not an image"), "malware.exe")}
22
+ resp = client.post("/upload", data=data, content_type="multipart/form-data")
23
+ assert resp.status_code == HTTPStatus.OK
24
+ assert b"Only image files are allowed" in resp.data
25
+
26
+ def test_valid_jpeg_redirects_to_gallery(self, client):
27
+ data = {"photo": (io.BytesIO(make_image_bytes("JPEG")), "photo.jpg")}
28
+ resp = client.post("/upload", data=data, content_type="multipart/form-data")
29
+ assert resp.status_code == HTTPStatus.FOUND
30
+ assert resp.headers["Location"].endswith("/gallery")
31
+
32
+ def test_valid_png_redirects_to_gallery(self, client):
33
+ data = {"photo": (io.BytesIO(make_image_bytes("PNG")), "photo.png")}
34
+ resp = client.post("/upload", data=data, content_type="multipart/form-data")
35
+ assert resp.status_code == HTTPStatus.FOUND
36
+ assert resp.headers["Location"].endswith("/gallery")
37
+
38
+ def test_upload_saves_file_to_s3(self, client):
39
+ data = {"photo": (io.BytesIO(make_image_bytes("JPEG")), "photo.jpg")}
40
+ client.post("/upload", data=data, content_type="multipart/form-data")
41
+
42
+ s3 = boto3.client("s3", region_name="us-east-1")
43
+ objects = s3.list_objects_v2(Bucket=TEST_BUCKET).get("Contents", [])
44
+ assert len(objects) == 1
45
+
46
+ def test_case_insensitive_extension(self, client):
47
+ data = {"photo": (io.BytesIO(make_image_bytes("JPEG")), "photo.JPG")}
48
+ resp = client.post("/upload", data=data, content_type="multipart/form-data")
49
+ assert resp.status_code == HTTPStatus.FOUND