snapshare 0.2.2__py3-none-any.whl

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.
snapshare/__init__.py ADDED
File without changes
snapshare/app.py ADDED
@@ -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)
snapshare/storage.py ADDED
@@ -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,9 @@
1
+ snapshare/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ snapshare/app.py,sha256=_XchyjQIIPWFB3U31Zv-Zn5LDFt5CWL1wi4WgzflP2w,2287
3
+ snapshare/storage.py,sha256=LMSJw3FcRNbmNF5LVjgdx6NrCjbFlxb3Thjhc-vMbeo,1289
4
+ snapshare/templates/gallery.html,sha256=_tcZHyelL0iZ8x5i3fUqQJX59Gs_QH0qU9QMsghPyPM,3511
5
+ snapshare/templates/index.html,sha256=GSwLIihl-9-32tB_Bxb8rLt4TtLTNPstKcGR-TL7Nas,5011
6
+ snapshare-0.2.2.dist-info/METADATA,sha256=uEINRQnHLXuhYnJSQJEhO4Hz-L-Q-NrbYKmX2P6NnX8,2921
7
+ snapshare-0.2.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ snapshare-0.2.2.dist-info/top_level.txt,sha256=RJlqKY32umbIGqgsIqmWjRqDvZLFJ9FVpaN0S7sFnL8,10
9
+ snapshare-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ snapshare