diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..e65ad0a --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/.gitignore b/.gitignore index b345356..ea85e57 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,10 @@ target/ # Ralph backup directories (created by migration) .ralph_backup_* + +# Coverage reports +ui/coverage/ +api/.coverage + +# TypeScript build info +*.tsbuildinfo diff --git a/README.md b/README.md index e687d96..c72ad7d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@
How It Works ·
+ Deployment ·
Getting Started ·
Architecture ·
Internationalization ·
@@ -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
diff --git a/api/.coverage b/api/.coverage
deleted file mode 100644
index ec342e4..0000000
Binary files a/api/.coverage and /dev/null differ
diff --git a/api/app.py b/api/app.py
index abc734c..1de7df8 100644
--- a/api/app.py
+++ b/api/app.py
@@ -9,6 +9,7 @@
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__)
@@ -16,6 +17,14 @@
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
@@ -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:
@@ -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)
@@ -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
@@ -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})
diff --git a/api/requirements.txt b/api/requirements.txt
index 205c34e..0d4753a 100644
--- a/api/requirements.txt
+++ b/api/requirements.txt
@@ -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
diff --git a/api/test_app.py b/api/test_app.py
index a5c6bf9..35c1318 100644
--- a/api/test_app.py
+++ b/api/test_app.py
@@ -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
@@ -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 ---------------
diff --git a/ui/Dockerfile b/ui/Dockerfile
index be9b72d..e44d6c4 100644
--- a/ui/Dockerfile
+++ b/ui/Dockerfile
@@ -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
diff --git a/ui/coverage/base.css b/ui/coverage/base.css
deleted file mode 100644
index f418035..0000000
--- a/ui/coverage/base.css
+++ /dev/null
@@ -1,224 +0,0 @@
-body, html {
- margin:0; padding: 0;
- height: 100%;
-}
-body {
- font-family: Helvetica Neue, Helvetica, Arial;
- font-size: 14px;
- color:#333;
-}
-.small { font-size: 12px; }
-*, *:after, *:before {
- -webkit-box-sizing:border-box;
- -moz-box-sizing:border-box;
- box-sizing:border-box;
- }
-h1 { font-size: 20px; margin: 0;}
-h2 { font-size: 14px; }
-pre {
- font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
- margin: 0;
- padding: 0;
- -moz-tab-size: 2;
- -o-tab-size: 2;
- tab-size: 2;
-}
-a { color:#0074D9; text-decoration:none; }
-a:hover { text-decoration:underline; }
-.strong { font-weight: bold; }
-.space-top1 { padding: 10px 0 0 0; }
-.pad2y { padding: 20px 0; }
-.pad1y { padding: 10px 0; }
-.pad2x { padding: 0 20px; }
-.pad2 { padding: 20px; }
-.pad1 { padding: 10px; }
-.space-left2 { padding-left:55px; }
-.space-right2 { padding-right:20px; }
-.center { text-align:center; }
-.clearfix { display:block; }
-.clearfix:after {
- content:'';
- display:block;
- height:0;
- clear:both;
- visibility:hidden;
- }
-.fl { float: left; }
-@media only screen and (max-width:640px) {
- .col3 { width:100%; max-width:100%; }
- .hide-mobile { display:none!important; }
-}
-
-.quiet {
- color: #7f7f7f;
- color: rgba(0,0,0,0.5);
-}
-.quiet a { opacity: 0.7; }
-
-.fraction {
- font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
- font-size: 10px;
- color: #555;
- background: #E8E8E8;
- padding: 4px 5px;
- border-radius: 3px;
- vertical-align: middle;
-}
-
-div.path a:link, div.path a:visited { color: #333; }
-table.coverage {
- border-collapse: collapse;
- margin: 10px 0 0 0;
- padding: 0;
-}
-
-table.coverage td {
- margin: 0;
- padding: 0;
- vertical-align: top;
-}
-table.coverage td.line-count {
- text-align: right;
- padding: 0 5px 0 20px;
-}
-table.coverage td.line-coverage {
- text-align: right;
- padding-right: 10px;
- min-width:20px;
-}
-
-table.coverage td span.cline-any {
- display: inline-block;
- padding: 0 5px;
- width: 100%;
-}
-.missing-if-branch {
- display: inline-block;
- margin-right: 5px;
- border-radius: 3px;
- position: relative;
- padding: 0 4px;
- background: #333;
- color: yellow;
-}
-
-.skip-if-branch {
- display: none;
- margin-right: 10px;
- position: relative;
- padding: 0 4px;
- background: #ccc;
- color: white;
-}
-.missing-if-branch .typ, .skip-if-branch .typ {
- color: inherit !important;
-}
-.coverage-summary {
- border-collapse: collapse;
- width: 100%;
-}
-.coverage-summary tr { border-bottom: 1px solid #bbb; }
-.keyline-all { border: 1px solid #ddd; }
-.coverage-summary td, .coverage-summary th { padding: 10px; }
-.coverage-summary tbody { border: 1px solid #bbb; }
-.coverage-summary td { border-right: 1px solid #bbb; }
-.coverage-summary td:last-child { border-right: none; }
-.coverage-summary th {
- text-align: left;
- font-weight: normal;
- white-space: nowrap;
-}
-.coverage-summary th.file { border-right: none !important; }
-.coverage-summary th.pct { }
-.coverage-summary th.pic,
-.coverage-summary th.abs,
-.coverage-summary td.pct,
-.coverage-summary td.abs { text-align: right; }
-.coverage-summary td.file { white-space: nowrap; }
-.coverage-summary td.pic { min-width: 120px !important; }
-.coverage-summary tfoot td { }
-
-.coverage-summary .sorter {
- height: 10px;
- width: 7px;
- display: inline-block;
- margin-left: 0.5em;
- background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
-}
-.coverage-summary .sorted .sorter {
- background-position: 0 -20px;
-}
-.coverage-summary .sorted-desc .sorter {
- background-position: 0 -10px;
-}
-.status-line { height: 10px; }
-/* yellow */
-.cbranch-no { background: yellow !important; color: #111; }
-/* dark red */
-.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
-.low .chart { border:1px solid #C21F39 }
-.highlighted,
-.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
- background: #C21F39 !important;
-}
-/* medium red */
-.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
-/* light red */
-.low, .cline-no { background:#FCE1E5 }
-/* light green */
-.high, .cline-yes { background:rgb(230,245,208) }
-/* medium green */
-.cstat-yes { background:rgb(161,215,106) }
-/* dark green */
-.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
-.high .chart { border:1px solid rgb(77,146,33) }
-/* dark yellow (gold) */
-.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
-.medium .chart { border:1px solid #f9cd0b; }
-/* light yellow */
-.medium { background: #fff4c2; }
-
-.cstat-skip { background: #ddd; color: #111; }
-.fstat-skip { background: #ddd; color: #111 !important; }
-.cbranch-skip { background: #ddd !important; color: #111; }
-
-span.cline-neutral { background: #eaeaea; }
-
-.coverage-summary td.empty {
- opacity: .5;
- padding-top: 4px;
- padding-bottom: 4px;
- line-height: 1;
- color: #888;
-}
-
-.cover-fill, .cover-empty {
- display:inline-block;
- height: 12px;
-}
-.chart {
- line-height: 0;
-}
-.cover-empty {
- background: white;
-}
-.cover-full {
- border-right: none !important;
-}
-pre.prettyprint {
- border: none !important;
- padding: 0 !important;
- margin: 0 !important;
-}
-.com { color: #999 !important; }
-.ignore-none { color: #999; font-weight: normal; }
-
-.wrapper {
- min-height: 100%;
- height: auto !important;
- height: 100%;
- margin: 0 auto -48px;
-}
-.footer, .push {
- height: 48px;
-}
diff --git a/ui/coverage/block-navigation.js b/ui/coverage/block-navigation.js
deleted file mode 100644
index 530d1ed..0000000
--- a/ui/coverage/block-navigation.js
+++ /dev/null
@@ -1,87 +0,0 @@
-/* eslint-disable */
-var jumpToCode = (function init() {
- // Classes of code we would like to highlight in the file view
- var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
-
- // Elements to highlight in the file listing view
- var fileListingElements = ['td.pct.low'];
-
- // We don't want to select elements that are direct descendants of another match
- var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
-
- // Selector that finds elements on the page to which we can jump
- var selector =
- fileListingElements.join(', ') +
- ', ' +
- notSelector +
- missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
-
- // The NodeList of matching elements
- var missingCoverageElements = document.querySelectorAll(selector);
-
- var currentIndex;
-
- function toggleClass(index) {
- missingCoverageElements
- .item(currentIndex)
- .classList.remove('highlighted');
- missingCoverageElements.item(index).classList.add('highlighted');
- }
-
- function makeCurrent(index) {
- toggleClass(index);
- currentIndex = index;
- missingCoverageElements.item(index).scrollIntoView({
- behavior: 'smooth',
- block: 'center',
- inline: 'center'
- });
- }
-
- function goToPrevious() {
- var nextIndex = 0;
- if (typeof currentIndex !== 'number' || currentIndex === 0) {
- nextIndex = missingCoverageElements.length - 1;
- } else if (missingCoverageElements.length > 1) {
- nextIndex = currentIndex - 1;
- }
-
- makeCurrent(nextIndex);
- }
-
- function goToNext() {
- var nextIndex = 0;
-
- if (
- typeof currentIndex === 'number' &&
- currentIndex < missingCoverageElements.length - 1
- ) {
- nextIndex = currentIndex + 1;
- }
-
- makeCurrent(nextIndex);
- }
-
- return function jump(event) {
- if (
- document.getElementById('fileSearch') === document.activeElement &&
- document.activeElement != null
- ) {
- // if we're currently focused on the search input, we don't want to navigate
- return;
- }
-
- switch (event.which) {
- case 78: // n
- case 74: // j
- goToNext();
- break;
- case 66: // b
- case 75: // k
- case 80: // p
- goToPrevious();
- break;
- }
- };
-})();
-window.addEventListener('keydown', jumpToCode);
diff --git a/ui/coverage/clover.xml b/ui/coverage/clover.xml
deleted file mode 100644
index 368dea9..0000000
--- a/ui/coverage/clover.xml
+++ /dev/null
@@ -1,187 +0,0 @@
-
-
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 | - - - - -18x -18x -18x - -32x - -18x - -8x -1x - - -10x -10x - - - -1x -1x - - -18x - - - -6x - - - - - - - - - -30x - - - - -1x - - - - - - - - - - - | import { useState, useRef, useEffect } from "react";
-import { useTranslation } from "react-i18next";
-import { LANGUAGES } from "../i18n";
-
-export default function LanguageSelector() {
- const { i18n } = useTranslation();
- const [open, setOpen] = useState(false);
- const ref = useRef<HTMLDivElement>(null);
-
- const current = LANGUAGES.find((l) => l.code === i18n.language) || LANGUAGES[0];
-
- useEffect(() => {
- function handleClick(e: MouseEvent) {
- if (ref.current && !ref.current.contains(e.target as Node)) {
- setOpen(false);
- }
- }
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, []);
-
- function select(code: string) {
- i18n.changeLanguage(code);
- setOpen(false);
- }
-
- return (
- <div className="lang-selector" ref={ref}>
- <button
- className="lang-trigger"
- onClick={() => setOpen(!open)}
- aria-label="Select language"
- aria-expanded={open}
- >
- <span className="lang-flag">{current.flag}</span>
- </button>
-
- {open && (
- <div className="lang-dropdown" role="listbox" aria-label="Languages">
- {LANGUAGES.map((lang) => (
- <button
- key={lang.code}
- className={`lang-option ${lang.code === i18n.language ? "lang-option--active" : ""}`}
- role="option"
- aria-selected={lang.code === i18n.language}
- onClick={() => select(lang.code)}
- >
- <span className="lang-flag">{lang.flag}</span>
- <span>{lang.label}</span>
- </button>
- ))}
- </div>
- )}
- </div>
- );
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 | - - - - - -4x - -4x - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | import { useTranslation } from "react-i18next";
-import { Shield, Lock, Eye, Trash2 } from "lucide-react";
-import SecurityModal from "./SecurityModal";
-import LanguageSelector from "./LanguageSelector";
-
-export default function Layout({ children }: { children: React.ReactNode }) {
- const { t } = useTranslation();
-
- return (
- <div className="layout">
- <header className="layout-header">
- <a href="/">
- <Shield size={22} className="header-icon" />
- <span className="header-title">{t("header.title")}</span>
- </a>
- <div className="header-actions">
- <LanguageSelector />
- <SecurityModal />
- </div>
- </header>
-
- <main className="layout-main">
- <div className="layout-content">{children}</div>
- </main>
-
- <footer className="layout-footer">
- <div className="footer-badges">
- <span className="footer-badge">
- <Lock size={13} />
- {t("footer.encryption")}
- </span>
- <span className="footer-badge">
- <Eye size={13} />
- {t("footer.zeroKnowledge")}
- </span>
- <span className="footer-badge">
- <Trash2 size={13} />
- {t("footer.autoDelete")}
- </span>
- </div>
- </footer>
- </div>
- );
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 | - - - - - - - - - - - - - - - -17x -17x - -17x - - - -5x - - - - - - -1x - - - - - -2x - - - - - - - - -1x - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | import { useState } from "react";
-import { useTranslation } from "react-i18next";
-import {
- HelpCircle,
- X,
- Shield,
- Lock,
- Eye,
- Trash2,
- Server,
- Hash,
- KeyRound,
- ShieldCheck,
-} from "lucide-react";
-
-export default function SecurityModal() {
- const { t } = useTranslation();
- const [open, setOpen] = useState(false);
-
- return (
- <>
- <button
- className="info-trigger"
- onClick={() => setOpen(true)}
- aria-label={t("security.title")}
- >
- <HelpCircle size={18} />
- </button>
-
- {open && (
- <div className="modal-overlay" onClick={() => setOpen(false)}>
- <div
- className="modal"
- role="dialog"
- aria-modal="true"
- aria-label={t("security.title")}
- onClick={(e) => e.stopPropagation()}
- >
- <div className="modal-header">
- <div className="modal-header-left">
- <Shield size={18} />
- <h2>{t("security.title")}</h2>
- </div>
- <button
- className="modal-close"
- onClick={() => setOpen(false)}
- aria-label="Close"
- >
- <X size={18} />
- </button>
- </div>
-
- <div className="modal-body">
- <div className="security-item">
- <div className="security-item-icon"><Lock size={16} /></div>
- <div>
- <h3>{t("security.e2eTitle")}</h3>
- <p>{t("security.e2eDesc")}</p>
- </div>
- </div>
- <div className="security-item">
- <div className="security-item-icon"><KeyRound size={16} /></div>
- <div>
- <h3>{t("security.hkdfTitle")}</h3>
- <p>{t("security.hkdfDesc")}</p>
- </div>
- </div>
- <div className="security-item">
- <div className="security-item-icon"><ShieldCheck size={16} /></div>
- <div>
- <h3>{t("security.aadTitle")}</h3>
- <p>{t("security.aadDesc")}</p>
- </div>
- </div>
- <div className="security-item">
- <div className="security-item-icon"><Eye size={16} /></div>
- <div>
- <h3>{t("security.zkTitle")}</h3>
- <p>{t("security.zkDesc")}</p>
- </div>
- </div>
- <div className="security-item">
- <div className="security-item-icon"><Hash size={16} /></div>
- <div>
- <h3>{t("security.keyTitle")}</h3>
- <p>{t("security.keyDesc")}</p>
- </div>
- </div>
- <div className="security-item">
- <div className="security-item-icon"><Trash2 size={16} /></div>
- <div>
- <h3>{t("security.oneTimeTitle")}</h3>
- <p>{t("security.oneTimeDesc")}</p>
- </div>
- </div>
- <div className="security-item">
- <div className="security-item-icon"><Server size={16} /></div>
- <div>
- <h3>{t("security.expiryTitle")}</h3>
- <p>{t("security.expiryDesc")}</p>
- </div>
- </div>
- </div>
- </div>
- </div>
- )}
- </>
- );
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| LanguageSelector.tsx | -
-
- |
- 100% | -17/17 | -90% | -9/10 | -100% | -9/9 | -100% | -15/15 | -
| Layout.tsx | -
-
- |
- 100% | -2/2 | -100% | -0/0 | -100% | -1/1 | -100% | -2/2 | -
| SecurityModal.tsx | -
-
- |
- 100% | -7/7 | -100% | -2/2 | -100% | -5/5 | -100% | -7/7 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| index.ts | -
-
- |
- 100% | -3/3 | -100% | -0/0 | -100% | -1/1 | -100% | -3/3 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 | - - - - - - - - - - -6x - - - - - - - - - - - - - - - - - - - -21x - - - - - -6x - - - - - - - - | import i18n from "i18next";
-import { initReactI18next } from "react-i18next";
-import LanguageDetector from "i18next-browser-languagedetector";
-
-import en from "./locales/en.json";
-import zh from "./locales/zh.json";
-import es from "./locales/es.json";
-import hi from "./locales/hi.json";
-import ar from "./locales/ar.json";
-import pt from "./locales/pt.json";
-
-i18n
- .use(LanguageDetector)
- .use(initReactI18next)
- .init({
- resources: {
- en: { translation: en },
- zh: { translation: zh },
- es: { translation: es },
- hi: { translation: hi },
- ar: { translation: ar },
- pt: { translation: pt },
- },
- fallbackLng: "en",
- interpolation: {
- escapeValue: false,
- },
- detection: {
- order: ["querystring", "localStorage", "navigator"],
- caches: ["localStorage"],
- lookupLocalStorage: "i18nextLng",
- convertDetectedLanguage: (lng: string) => lng.split("-")[0],
- },
- });
-
-export default i18n;
-
-export const LANGUAGES = [
- { code: "en", flag: "\u{1F1FA}\u{1F1F8}", label: "English" },
- { code: "zh", flag: "\u{1F1E8}\u{1F1F3}", label: "\u4E2D\u6587" },
- { code: "es", flag: "\u{1F1EA}\u{1F1F8}", label: "Espa\u00F1ol" },
- { code: "hi", flag: "\u{1F1EE}\u{1F1F3}", label: "\u0939\u093F\u0928\u094D\u0926\u0940" },
- { code: "ar", flag: "\u{1F1F8}\u{1F1E6}", label: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629" },
- { code: "pt", flag: "\u{1F1E7}\u{1F1F7}", label: "Portugu\u00EAs" },
-];
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| components | -
-
- |
- 100% | -26/26 | -91.66% | -11/12 | -100% | -15/15 | -100% | -24/24 | -
| i18n | -
-
- |
- 100% | -3/3 | -100% | -0/0 | -100% | -1/1 | -100% | -3/3 | -
| lib | -
-
- |
- 100% | -53/53 | -100% | -14/14 | -100% | -12/12 | -100% | -52/52 | -
| pages | -
-
- |
- 95.65% | -66/69 | -93.75% | -45/48 | -86.66% | -13/15 | -100% | -66/66 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 | - - - - - - - - - -4x - - - - - -4x -2x -2x - - -2x -2x - - - - - - - - -4x - -4x -1x - - -3x -2x -2x - - -1x -1x - - | export interface CreateSecretResult {
- id: string;
- alias: string | null;
-}
-
-export async function createSecret(
- ciphertext: string,
- ttlHours: number,
- id: string,
-): Promise<CreateSecretResult> {
- const res = await fetch("/api/secrets", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ciphertext, ttl_hours: ttlHours, id }),
- });
-
- if (!res.ok) {
- const err = await res.json();
- throw new Error(err.error || "Failed to create secret");
- }
-
- const data = await res.json();
- return { id: data.id, alias: data.alias ?? null };
-}
-
-export interface GetSecretResult {
- ciphertext: string;
- id: string;
-}
-
-export async function getSecret(id: string): Promise<GetSecretResult> {
- const res = await fetch(`/api/secrets/${id}`);
-
- if (res.status === 404) {
- throw new Error("Secret not found or already viewed");
- }
-
- if (!res.ok) {
- const err = await res.json();
- throw new Error(err.error || "Failed to retrieve secret");
- }
-
- const data = await res.json();
- return { ciphertext: data.ciphertext, id: data.id };
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 | - - - - - - - - - -1x -1x - - - - -2x - - - - - - -1x -1x -32x - - - - - - -13x - - - - - - - -2x -2x - - - - -1x -1x - - - - - - - - - - - - - - - - - - - - -15x - -15x - - - - - - - -15x -15x - - - - - - - - - - - - - - - - - - - - - - - - - - -10x -10x -10x -10x - -10x - - - - - - -10x - - -10x -10x -10x -10x -10x - -10x - - - - - - - - - - - - -6x -226x - - -6x -6x -1x - - -5x -5x -5x - -5x - -5x - - - - - -3x - - | /**
- * Only Once Share — Client-Side Encryption Module
- *
- * Cipher: AES-256-GCM (authenticated encryption)
- * KDF: HKDF-SHA-256 (derives per-secret key from master key + secret ID)
- * IV: 96-bit random per encryption (NIST recommended)
- * AAD: Secret ID bound as additional authenticated data
- * Format: [version 1B] [iv 12B] [ciphertext + GCM tag]
- */
-
-const VERSION = 0x01;
-const IV_BYTES = 12;
-
-// --------------- base64url helpers ---------------
-
-function base64urlEncode(bytes: Uint8Array): string {
- return btoa(String.fromCharCode(...bytes))
- .replace(/\+/g, "-")
- .replace(/\//g, "_")
- .replace(/=+$/, "");
-}
-
-function base64urlDecode(str: string): Uint8Array {
- const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
- const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
- return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
-}
-
-// --------------- key management ---------------
-
-/** Generate a 256-bit master key (stored in URL fragment). */
-export async function generateKey(): Promise<CryptoKey> {
- return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
- "encrypt",
- "decrypt",
- ]);
-}
-
-/** Export master key to base64url string for the URL fragment. */
-export async function exportKey(key: CryptoKey): Promise<string> {
- const raw = await crypto.subtle.exportKey("raw", key);
- return base64urlEncode(new Uint8Array(raw));
-}
-
-/** Import master key from base64url string. */
-export async function importKey(base64url: string): Promise<CryptoKey> {
- const raw = base64urlDecode(base64url);
- return crypto.subtle.importKey(
- "raw",
- raw,
- { name: "AES-GCM", length: 256 },
- true,
- ["encrypt", "decrypt"],
- );
-}
-
-// --------------- HKDF key derivation ---------------
-
-/**
- * Derive a per-secret AES-256-GCM key from the master key using HKDF-SHA-256.
- * The secret ID is used as the `info` parameter, binding the derived key
- * to this specific secret. An attacker cannot reuse ciphertext across secrets.
- */
-async function deriveKey(
- masterKey: CryptoKey,
- secretId: string,
-): Promise<CryptoKey> {
- // Export master key raw bytes to use as HKDF input keying material
- const rawKey = await crypto.subtle.exportKey("raw", masterKey);
-
- const hkdfKey = await crypto.subtle.importKey(
- "raw",
- rawKey,
- { name: "HKDF" },
- false,
- ["deriveKey"],
- );
-
- const encoder = new TextEncoder();
- return crypto.subtle.deriveKey(
- {
- name: "HKDF",
- hash: "SHA-256",
- salt: encoder.encode("only-once-share-v1"),
- info: encoder.encode(secretId),
- },
- hkdfKey,
- { name: "AES-GCM", length: 256 },
- false,
- ["encrypt", "decrypt"],
- );
-}
-
-// --------------- encrypt / decrypt ---------------
-
-/**
- * Encrypt plaintext with AES-256-GCM.
- * - Derives a unique key via HKDF(masterKey, secretId)
- * - Uses the secret ID as Additional Authenticated Data (AAD)
- * - Output: base64( version || iv || ciphertext+tag )
- */
-export async function encrypt(
- plaintext: string,
- masterKey: CryptoKey,
- secretId: string,
-): Promise<string> {
- const derivedKey = await deriveKey(masterKey, secretId);
- const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
- const aad = new TextEncoder().encode(secretId);
- const encoded = new TextEncoder().encode(plaintext);
-
- const ciphertext = await crypto.subtle.encrypt(
- { name: "AES-GCM", iv, additionalData: aad },
- derivedKey,
- encoded,
- );
-
- // Wipe plaintext from memory
- encoded.fill(0);
-
- // Assemble: [version 1B] [iv 12B] [ciphertext+tag]
- const ctBytes = new Uint8Array(ciphertext);
- const combined = new Uint8Array(1 + IV_BYTES + ctBytes.length);
- combined[0] = VERSION;
- combined.set(iv, 1);
- combined.set(ctBytes, 1 + IV_BYTES);
-
- return btoa(String.fromCharCode(...combined));
-}
-
-/**
- * Decrypt ciphertext with AES-256-GCM.
- * - Derives the same key via HKDF(masterKey, secretId)
- * - Verifies AAD matches the secret ID (tamper detection)
- */
-export async function decrypt(
- base64Ciphertext: string,
- masterKey: CryptoKey,
- secretId: string,
-): Promise<string> {
- const combined = Uint8Array.from(atob(base64Ciphertext), (c) =>
- c.charCodeAt(0),
- );
-
- const version = combined[0];
- if (version !== VERSION) {
- throw new Error("Unsupported encryption version");
- }
-
- const iv = combined.slice(1, 1 + IV_BYTES);
- const ciphertext = combined.slice(1 + IV_BYTES);
- const aad = new TextEncoder().encode(secretId);
-
- const derivedKey = await deriveKey(masterKey, secretId);
-
- const decrypted = await crypto.subtle.decrypt(
- { name: "AES-GCM", iv, additionalData: aad },
- derivedKey,
- ciphertext,
- );
-
- return new TextDecoder().decode(decrypted);
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 | - - - - - - - - - - - - - - - -1x - - - - - - - - - -75x -75x -75x -75x -75x -75x -75x - - -8x -8x - -8x -8x -8x - -8x -8x -8x -8x -7x -6x -6x -8x -8x - -2x - -8x - - - - -1x -1x -1x - - - -1x -1x - - - -7x -7x - - - -7x -7x -7x - - -75x - - - - - - - - - - - - - - - - - -42x - - - - - - - - - - - - - - - - - -408x - - - - -1x - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | import { useState } from "react";
-import { useTranslation } from "react-i18next";
-import {
- Lock,
- Clock,
- Copy,
- Check,
- Plus,
- AlertCircle,
- CheckCircle2,
- Loader2,
- Mail,
-} from "lucide-react";
-import { generateKey, exportKey, encrypt } from "../lib/crypto";
-import { createSecret } from "../lib/api";
-
-const TTL_OPTIONS = [
- { value: 1, label: "1h" },
- { value: 4, label: "4h" },
- { value: 12, label: "12h" },
- { value: 24, label: "24h" },
- { value: 48, label: "48h" },
- { value: 72, label: "72h" },
-];
-
-export default function CreateSecret() {
- const { t, i18n } = useTranslation();
- const [secret, setSecret] = useState("");
- const [ttlHours, setTtlHours] = useState(24);
- const [link, setLink] = useState("");
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState("");
- const [copied, setCopied] = useState(false);
-
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- Iif (!secret.trim()) return;
-
- setLoading(true);
- setError("");
- setLink("");
-
- try {
- const id = crypto.randomUUID();
- const key = await generateKey();
- const ciphertext = await encrypt(secret, key, id);
- const result = await createSecret(ciphertext, ttlHours, id);
- const keyStr = await exportKey(key);
- const pathId = result.alias ?? result.id;
- setLink(`${window.location.origin}/s/${pathId}?lng=${i18n.language}#${keyStr}`);
- setSecret("");
- } catch (err) {
- setError(err instanceof Error ? err.message : "Something went wrong");
- } finally {
- setLoading(false);
- }
- }
-
- async function handleCopy() {
- await navigator.clipboard.writeText(link);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
-
- function handleReset() {
- setLink("");
- setCopied(false);
- }
-
- function whatsappUrl() {
- const text = t("create.whatsappMsg", { link });
- return `https://wa.me/?text=${encodeURIComponent(text)}`;
- }
-
- function mailtoUrl() {
- const subject = t("create.emailSubject");
- const body = t("create.emailBody", { link });
- return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
- }
-
- return (
- <>
- <div className="hero">
- <h1 className="hero-title">{t("hero.title")}</h1>
- <p className="hero-subtitle">{t("hero.subtitle")}</p>
- </div>
-
- <div className="card">
- {!link ? (
- <form className="form" onSubmit={handleSubmit}>
- <div className="form-group">
- <label className="form-label" htmlFor="secret-input">
- <Lock size={14} />
- {t("create.label")}
- </label>
- <textarea
- id="secret-input"
- value={secret}
- onChange={(e) => setSecret(e.target.value)}
- placeholder={t("create.placeholder")}
- rows={6}
- maxLength={50000}
- required
- />
- <span className="char-count">
- {secret.length.toLocaleString()} / 50,000
- </span>
- </div>
-
- <div className="ttl-group">
- <span className="form-label">
- <Clock size={14} />
- {t("create.expiresIn")}
- </span>
- <div className="ttl-options" role="group" aria-label={t("create.expiresIn")}>
- {TTL_OPTIONS.map((opt) => (
- <button
- key={opt.value}
- type="button"
- className="ttl-option"
- aria-pressed={ttlHours === opt.value}
- onClick={() => setTtlHours(opt.value)}
- >
- {opt.label}
- </button>
- ))}
- </div>
- </div>
-
- {error && (
- <div className="error-msg">
- <AlertCircle size={15} />
- <span>{error}</span>
- </div>
- )}
-
- <button
- type="submit"
- className="btn btn-primary btn-full"
- disabled={loading || !secret.trim()}
- >
- {loading ? (
- <>
- <Loader2 size={16} className="spinning" />
- {t("create.encrypting")}
- </>
- ) : (
- <>
- <Lock size={16} />
- {t("create.submit")}
- </>
- )}
- </button>
- </form>
- ) : (
- <div className="result">
- <div className="result-header">
- <CheckCircle2 size={18} />
- <span>{t("create.linkCreated")}</span>
- </div>
-
- <p className="result-info">{t("create.linkInfo")}</p>
-
- <div className="link-box">
- <div className="link-display">{link}</div>
- </div>
-
- <div className="share-label">{t("create.shareVia")}</div>
- <div className="share-buttons">
- <button
- className={`share-btn ${copied ? "share-btn--copied" : ""}`}
- onClick={handleCopy}
- aria-label={t("create.copy")}
- >
- {copied ? <Check size={17} /> : <Copy size={17} />}
- <span>{copied ? t("create.copied") : t("create.copy")}</span>
- </button>
-
- <a
- className="share-btn share-btn--whatsapp"
- href={whatsappUrl()}
- target="_blank"
- rel="noopener noreferrer"
- aria-label={t("create.whatsapp")}
- >
- <svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
- <path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
- </svg>
- <span>{t("create.whatsapp")}</span>
- </a>
-
- <a
- className="share-btn share-btn--email"
- href={mailtoUrl()}
- aria-label={t("create.email")}
- >
- <Mail size={17} />
- <span>{t("create.email")}</span>
- </a>
- </div>
-
- <button className="btn btn-secondary btn-full" onClick={handleReset}>
- <Plus size={16} />
- {t("create.createAnother")}
- </button>
- </div>
- )}
- </div>
- </>
- );
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 | - - - - - - - - - - - - - - - - -20x -20x -20x -20x -20x -20x - -20x - -10x -10x -2x -2x -2x - - -8x -8x -4x -4x -3x -3x - -4x -4x -2x - -2x -2x - - - - -10x - - - -1x -1x -1x - - -20x - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | import { useEffect, useState } from "react";
-import { useParams } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import {
- ShieldOff,
- ArrowLeft,
- EyeOff,
- AlertCircle,
- Copy,
- Check,
-} from "lucide-react";
-import { importKey, decrypt } from "../lib/crypto";
-import { getSecret } from "../lib/api";
-
-type Status = "loading" | "revealed" | "not-found" | "error";
-
-export default function ViewSecret() {
- const { t } = useTranslation();
- const { id } = useParams<{ id: string }>();
- const [status, setStatus] = useState<Status>("loading");
- const [plaintext, setPlaintext] = useState("");
- const [error, setError] = useState("");
- const [copied, setCopied] = useState(false);
-
- useEffect(() => {
- async function fetchAndDecrypt() {
- const keyStr = window.location.hash.slice(1);
- if (!keyStr || !id) {
- setError(t("view.invalidLink"));
- setStatus("error");
- return;
- }
-
- try {
- const result = await getSecret(id);
- const key = await importKey(keyStr);
- const decrypted = await decrypt(result.ciphertext, key, result.id);
- setPlaintext(decrypted);
- setStatus("revealed");
- } catch (err) {
- const msg = err instanceof Error ? err.message : "Failed to decrypt";
- if (msg.includes("not found") || msg.includes("already viewed")) {
- setStatus("not-found");
- } else {
- setError(msg);
- setStatus("error");
- }
- }
- }
-
- fetchAndDecrypt();
- }, [id, t]);
-
- async function handleCopy() {
- await navigator.clipboard.writeText(plaintext);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
-
- return (
- <div className="view-container">
- {status === "loading" && (
- <div className="card">
- <div className="loading-card">
- <div className="loading-spinner" />
- <p>{t("view.loading")}</p>
- </div>
- </div>
- )}
-
- {status === "revealed" && (
- <div className="card">
- <div className="revealed-card">
- <div className="destroyed-banner">
- <ShieldOff size={15} />
- <span>{t("view.destroyed")}</span>
- </div>
- <div className="secret-content">{plaintext}</div>
- <button
- className={`btn btn-sm btn-full ${copied ? "btn-success" : "btn-secondary"}`}
- onClick={handleCopy}
- aria-label={t("view.copySecret")}
- >
- {copied ? <Check size={15} /> : <Copy size={15} />}
- {copied ? t("view.copiedClipboard") : t("view.copySecret")}
- </button>
- </div>
- </div>
- )}
-
- {status === "not-found" && (
- <div className="card">
- <div className="not-found-card">
- <div className="not-found-icon">
- <EyeOff size={22} />
- </div>
- <h2>{t("view.notFoundTitle")}</h2>
- <p>{t("view.notFoundMsg")}</p>
- </div>
- </div>
- )}
-
- {status === "error" && (
- <div className="card">
- <div className="error-card">
- <div className="error-icon">
- <AlertCircle size={22} />
- </div>
- <h2>{t("view.errorTitle")}</h2>
- <p>{error || t("view.errorMsg")}</p>
- </div>
- </div>
- )}
-
- <a href="/" className="back-link">
- <ArrowLeft size={15} />
- {status === "revealed" ? t("view.newSecret") : t("view.backHome")}
- </a>
- </div>
- );
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| CreateSecret.tsx | -
-
- |
- 95% | -38/40 | -90% | -18/20 | -90% | -9/10 | -100% | -38/38 | -
| ViewSecret.tsx | -
-
- |
- 96.55% | -28/29 | -96.42% | -27/28 | -80% | -4/5 | -100% | -28/28 | -