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.
- snapshare-0.2.2/.dockerignore +16 -0
- snapshare-0.2.2/.github/dependabot.yml +16 -0
- snapshare-0.2.2/.github/workflows/ci.yml +28 -0
- snapshare-0.2.2/.github/workflows/deploy.yml +44 -0
- snapshare-0.2.2/.gitignore +8 -0
- snapshare-0.2.2/.pre-commit-config.yaml +7 -0
- snapshare-0.2.2/Dockerfile +14 -0
- snapshare-0.2.2/PKG-INFO +99 -0
- snapshare-0.2.2/README.md +81 -0
- snapshare-0.2.2/fly.toml +21 -0
- snapshare-0.2.2/pyproject.toml +51 -0
- snapshare-0.2.2/setup.cfg +4 -0
- snapshare-0.2.2/src/snapshare/__init__.py +0 -0
- snapshare-0.2.2/src/snapshare/app.py +78 -0
- snapshare-0.2.2/src/snapshare/storage.py +45 -0
- snapshare-0.2.2/src/snapshare/templates/gallery.html +145 -0
- snapshare-0.2.2/src/snapshare/templates/index.html +176 -0
- snapshare-0.2.2/src/snapshare.egg-info/PKG-INFO +99 -0
- snapshare-0.2.2/src/snapshare.egg-info/SOURCES.txt +26 -0
- snapshare-0.2.2/src/snapshare.egg-info/dependency_links.txt +1 -0
- snapshare-0.2.2/src/snapshare.egg-info/requires.txt +12 -0
- snapshare-0.2.2/src/snapshare.egg-info/scm_file_list.json +23 -0
- snapshare-0.2.2/src/snapshare.egg-info/scm_version.json +8 -0
- snapshare-0.2.2/src/snapshare.egg-info/top_level.txt +1 -0
- snapshare-0.2.2/tests/conftest.py +39 -0
- snapshare-0.2.2/tests/test_gallery.py +42 -0
- snapshare-0.2.2/tests/test_ip_restriction.py +44 -0
- snapshare-0.2.2/tests/test_upload.py +49 -0
|
@@ -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,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"]
|
snapshare-0.2.2/PKG-INFO
ADDED
|
@@ -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
|
snapshare-0.2.2/fly.toml
ADDED
|
@@ -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"]
|
|
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">×</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 & 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 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
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
|