Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
pull_request:
branches: [main]

jobs:
api-tests:
name: API Tests
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: pip install -r api/requirements.txt pytest pytest-cov

- name: Run API tests with coverage
env:
REDIS_HOST: localhost
REDIS_PORT: 6379
run: |
cd api
python -m pytest test_app.py -v \
--cov=app \
--cov-report=term-missing \
--cov-fail-under=99

ui-tests:
name: UI Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: ui/package-lock.json

- name: Install dependencies
run: cd ui && npm ci

- name: Run UI tests with coverage
run: |
cd ui
npx vitest run --coverage \
--coverage.thresholds.lines=99
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,10 @@ target/

# Ralph backup directories (created by migration)
.ralph_backup_*

# Coverage reports
ui/coverage/
api/.coverage

# TypeScript build info
*.tsbuildinfo
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<p align="center">
<a href="#how-it-works">How It Works</a> ·
<a href="#deployment">Deployment</a> ·
<a href="#getting-started">Getting Started</a> ·
<a href="#architecture">Architecture</a> ·
<a href="#i18n">Internationalization</a> ·
Expand Down Expand Up @@ -143,6 +144,32 @@ https://example.com/s/Kx7mP2nQ?lng=en#iZcjqbPIBnrWwHHkv_KDWeDcUr9hi3A0oMaVbgCVLr
| **Versioned format** | Ciphertext includes version byte for future algorithm upgrades |
| **Short aliases** | 8-char base62 IDs (62^8 = 218 trillion), atomic collision-free generation |

## Deployment

Only Once Share can be used in two ways:

### Cloud (Hosted)

Start sharing secrets immediately at **[https://ooshare.io](https://ooshare.io)** — no setup required. The hosted version runs the same open-source code from this repository.

### On-Premise (Self-Hosted)

If your organization requires full control over the infrastructure — for compliance, data residency, or security policies — you can deploy Only Once Share on your own servers.

**What you need:**
- A container runtime (Docker, Kubernetes, ECS, etc.)
- A Redis instance (managed or self-hosted)
- A reverse proxy or load balancer for TLS termination

**Steps:**
1. Clone this repository
2. Build the API and UI Docker images (see [Getting Started](#getting-started))
3. Deploy a Redis instance and set the `REDIS_URL` environment variable on the API
4. Set `VITE_API_URL` to your API's URL when building the UI (or update the default in `ui/Dockerfile`)
5. Configure DNS and TLS for your domain

The architecture is stateless (aside from Redis), so it scales horizontally with no changes. All encryption happens client-side — the server never sees plaintext regardless of where it's deployed.

## Getting Started

### Prerequisites
Expand Down
Binary file removed api/.coverage
Binary file not shown.
21 changes: 18 additions & 3 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@
from flask import Flask, request, jsonify
from flask_cors import CORS
import redis
from posthog import Posthog

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)

app = Flask(__name__)
CORS(app)

POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY")
if POSTHOG_API_KEY: # pragma: no cover
posthog = Posthog(POSTHOG_API_KEY, host="https://us.i.posthog.com")
log.info("PostHog initialized")
else:
posthog = None
log.info("PostHog disabled (POSTHOG_API_KEY not set)")

MAX_CIPHERTEXT_SIZE = 100 * 1024 # 100KB
ALIAS_ALPHABET = string.ascii_letters + string.digits
ALIAS_LENGTH = 8
Expand All @@ -27,7 +36,7 @@ def generate_alias():


REDIS_URL = os.environ.get("REDIS_URL")
if REDIS_URL:
if REDIS_URL: # pragma: no cover
log.info("Connecting to Redis via REDIS_URL")
r = redis.from_url(REDIS_URL, decode_responses=True, socket_timeout=5)
else:
Expand All @@ -41,10 +50,10 @@ def generate_alias():
socket_timeout=5,
)

try:
try: # pragma: no cover
r.ping()
log.info("Redis connection established")
except redis.ConnectionError as e:
except redis.ConnectionError as e: # pragma: no cover
log.error("Redis connection failed: %s", e)


Expand Down Expand Up @@ -121,6 +130,9 @@ def create_secret():
if alias is None:
log.error("Failed to generate alias after 5 attempts for secret %s", secret_id)

if posthog:
posthog.capture("server", "secret_created", {"ttl_hours": ttl_hours, "has_alias": alias is not None})

return jsonify({"id": secret_id, "alias": alias}), 201


Expand Down Expand Up @@ -160,6 +172,9 @@ def get_secret(secret_id):
r.delete(f"alias:{alias_used}")
log.info("Alias cleaned up: %s", alias_used)

if posthog:
posthog.capture("server", "secret_retrieved", {"via": "alias" if alias_used else "uuid"})

return jsonify({"ciphertext": ciphertext, "id": actual_id})


Expand Down
1 change: 1 addition & 0 deletions api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ flask==3.1.0
flask-cors==5.0.1
redis==5.2.1
gunicorn==23.0.0
posthog==3.24.0
50 changes: 50 additions & 0 deletions api/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
from app import app, generate_alias, ALIAS_LENGTH


@pytest.fixture(autouse=True)
def mock_posthog():
"""Mock PostHog so tests don't send real events."""
mock = MagicMock()
with patch("app.posthog", mock):
yield mock


@pytest.fixture
def client():
app.config["TESTING"] = True
Expand Down Expand Up @@ -306,6 +314,48 @@ def test_get_secret_alias_points_to_expired_secret(client, mock_redis):
assert res.status_code == 404


# --------------- PostHog events ---------------


def test_posthog_secret_created(client, mock_posthog):
client.post("/api/secrets", json={"ciphertext": "dGVzdA==", "ttl_hours": 4})
mock_posthog.capture.assert_called_with(
"server", "secret_created", {"ttl_hours": 4, "has_alias": True}
)


def test_posthog_secret_retrieved(client, mock_posthog):
res = client.post("/api/secrets", json={"ciphertext": "dGVzdA=="})
alias = res.get_json()["alias"]
mock_posthog.reset_mock()

client.get(f"/api/secrets/{alias}")
mock_posthog.capture.assert_called_with(
"server", "secret_retrieved", {"via": "alias"}
)


def test_posthog_secret_retrieved_by_uuid(client, mock_posthog):
res = client.post("/api/secrets", json={"ciphertext": "dGVzdA=="})
secret_id = res.get_json()["id"]
mock_posthog.reset_mock()

client.get(f"/api/secrets/{secret_id}")
mock_posthog.capture.assert_called_with(
"server", "secret_retrieved", {"via": "uuid"}
)


def test_posthog_disabled(client, mock_redis):
"""When posthog is None, no errors occur."""
with patch("app.posthog", None):
res = client.post("/api/secrets", json={"ciphertext": "dGVzdA=="})
assert res.status_code == 201
secret_id = res.get_json()["id"]
res = client.get(f"/api/secrets/{secret_id}")
assert res.status_code == 200


# --------------- main guard ---------------


Expand Down
2 changes: 2 additions & 0 deletions ui/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ COPY package.json package-lock.json* ./
RUN npm install
COPY . .
ARG VITE_API_URL=https://oos-api.onrender.com
ARG VITE_POSTHOG_KEY
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_POSTHOG_KEY=$VITE_POSTHOG_KEY
RUN npm run build

# Production stage
Expand Down
Loading
Loading