Compare commits
10
Commits
f0eaeb877f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93f96a7332 | ||
|
|
de017be928 | ||
|
|
fcfa3cb373 | ||
|
|
b7ff709af4 | ||
|
|
f48d4948fa | ||
|
|
1eb240a412 | ||
|
|
6296ac62cc | ||
|
|
9dfead2072 | ||
|
|
d830cd15c1 | ||
|
|
49943b77a7 |
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
# CVault — Private Code Snippet Manager
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
CVault is a private alternative to GitHub Gist. Snippets are shareable via secret links or password-protected. The app now includes a local backend with username/password accounts, Redis-backed CAPTCHA/session/rate-limit state, HTTP-only sessions, server-side snippet persistence, and server-side password/expiration checks.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- Next.js 16 (App Router, TypeScript, Tailwind CSS v4)
|
||||||
|
- shadcn/ui — component library (Radix + Tailwind)
|
||||||
|
- Framer Motion — animations (page transitions, stagger, layout, hover)
|
||||||
|
- @uiw/react-codemirror — code editor with syntax highlighting
|
||||||
|
- next-themes — dark/light mode toggle
|
||||||
|
- Lucide React — icons
|
||||||
|
- Geist + Geist Mono — local fonts (no CDN, served from /public/fonts/)
|
||||||
|
|
||||||
|
## Color Palette (No Purple)
|
||||||
|
- Primary: green-500 (#22c55e) / green-600 hover
|
||||||
|
- Accent: amber-500 (#f59e0b) — for password/highlights
|
||||||
|
- Background dark: zinc-950 / light: zinc-50
|
||||||
|
- Card dark: zinc-900 / light: white
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
| Route | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Landing — hero, feature cards, recent snippets |
|
||||||
|
| `/login` | Username/password sign-in with local CAPTCHA |
|
||||||
|
| `/register` | Local account creation with CAPTCHA |
|
||||||
|
| `/snippets` | Library — grid/list, search, filter |
|
||||||
|
| `/snippets/new` | Create — code/text toggle, CodeMirror or textarea |
|
||||||
|
| `/snippets/[id]` | Owner view — syntax highlight, copy, share, edit, delete |
|
||||||
|
| `/snippets/[id]/edit` | Edit — pre-filled form |
|
||||||
|
| `/s/[id]` | Shared — minimal chrome, password gate |
|
||||||
|
| `/raw/[id]` | Raw plain-text output — 403 for password-protected, 404 if expired |
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
| Component | File | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Header | `components/header.tsx` | Nav, theme toggle, mobile menu |
|
||||||
|
| Footer | `components/footer.tsx` | Coaxys credit + arsamsabbagh.ir link |
|
||||||
|
| Hero | `components/hero.tsx` | Gradient text, stagger cards |
|
||||||
|
| SnippetCard | `components/snippet-card.tsx` | Layout animation, grid/list |
|
||||||
|
| CodeEditor | `components/code-editor.tsx` | CodeMirror wrapper, 13+ langs |
|
||||||
|
| CodeViewer | `components/code-viewer.tsx` | Read-only + copy button |
|
||||||
|
| PrivacyToggle | `components/privacy-toggle.tsx` | Secret/Password slider |
|
||||||
|
| PasswordGate | `components/password-gate.tsx` | Password modal |
|
||||||
|
| SearchBar | `components/search-bar.tsx` | Live filter |
|
||||||
|
| ViewToggle | `components/view-toggle.tsx` | Grid/list switch |
|
||||||
|
| TagInput | `components/tag-input.tsx` | Comma-separated tags |
|
||||||
|
| LanguageSelector | `components/language-selector.tsx` | Language dropdown |
|
||||||
|
| ExpirationSelector | `components/expiration-selector.tsx` | Expiry dropdown |
|
||||||
|
| EmptyState | `components/empty-state.tsx` | Floating icon illustration |
|
||||||
|
| ThemeProvider | `components/theme-provider.tsx` | next-themes wrapper |
|
||||||
|
| PageTransition | `components/page-transition.tsx` | AnimatePresence wrapper |
|
||||||
|
|
||||||
|
## Animation Strategy
|
||||||
|
- All Framer Motion `initial` props set to `false` — content visible by default on SSR, animations play on client mount
|
||||||
|
- `whileHover` / `whileTap` micro-interactions on buttons/cards
|
||||||
|
- Stagger children on card grids
|
||||||
|
- Layout animations on grid/list toggle
|
||||||
|
- Hero gradient shimmer via CSS keyframes
|
||||||
|
|
||||||
|
## Data Layer
|
||||||
|
- `lib/types.ts` — Snippet interface, PrivacyType, Expiration, LANGUAGES list
|
||||||
|
- `lib/mock-data.ts` — legacy demo snippets only; production does not seed demo users/snippets
|
||||||
|
- `lib/server-db.ts` — local JSON datastore in `data/db.json`, expiration helpers, public snippet sanitizer
|
||||||
|
- `lib/server-redis.ts` — Redis client with in-memory fallback
|
||||||
|
- `lib/server-security.ts` — HTTP-only sessions and local CAPTCHA backed by Redis
|
||||||
|
- `lib/server-rate-limit.ts` — Redis-backed rate limiting
|
||||||
|
- `lib/server-password.ts` — password hashing and verification
|
||||||
|
- `lib/api-client.ts` — client-side API wrapper
|
||||||
|
- `app/api/**` — auth, snippet CRUD, and shared-snippet route handlers
|
||||||
|
- `lib/utils.ts` — cn() from shadcn
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
- Public URL: `https://snp.coaxys.ir`
|
||||||
|
- Production build: `npm run build`
|
||||||
|
- Process manager: `cvault.service` systemd unit
|
||||||
|
- App upstream: `next start -H 127.0.0.1 -p 3005`
|
||||||
|
- Nginx reverse proxy: HTTPS on port 443 → `127.0.0.1:3005`
|
||||||
|
- Redis: `REDIS_URL=redis://127.0.0.1:6379`
|
||||||
|
- Service env: `CVAULT_SECRET`, `CVAULT_SECURE_COOKIES=true`, `REDIS_URL`
|
||||||
|
- All runtime dependencies local (fonts in `/public/fonts/`, icons and editor bundled from npm, no CDN/remote ESM)
|
||||||
|
- CSP restricts scripts, styles, fonts, images, media, and API connections to local/self sources
|
||||||
|
|
||||||
|
## Production Commands
|
||||||
|
- Rebuild app: `npm run build`
|
||||||
|
- Restart app: `systemctl restart cvault.service`
|
||||||
|
- Check app: `systemctl status cvault.service --no-pager -l`
|
||||||
|
- Check nginx: `nginx -t && systemctl reload nginx`
|
||||||
|
- Check Redis: `redis-cli ping`
|
||||||
|
- Inspect Redis CVault keys: `redis-cli --scan --pattern 'cvault:*'`
|
||||||
|
|
||||||
|
## Deployment Guide
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Ubuntu/Debian server
|
||||||
|
- Node.js 20+ (`node --version`)
|
||||||
|
- npm 10+ (`npm --version`)
|
||||||
|
- Redis (`apt install redis-server`)
|
||||||
|
- Nginx (`apt install nginx`)
|
||||||
|
- Certbot for SSL (`apt install certbot python3-certbot-nginx`)
|
||||||
|
|
||||||
|
### 1. Transfer / Clone the Repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option A — git clone (once you push to a remote)
|
||||||
|
git clone <repo-url> /srv/CVault/vault
|
||||||
|
|
||||||
|
# Option B — rsync from old server
|
||||||
|
rsync -av --exclude='.next' --exclude='node_modules' --exclude='data/' \
|
||||||
|
user@old-server:/srv/CVault/vault/ /srv/CVault/vault/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/CVault/vault
|
||||||
|
npm ci # uses package-lock.json exactly — never npm install in production
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Environment Variables
|
||||||
|
|
||||||
|
Create `/etc/cvault.env` (owned by root, mode 600):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /etc/cvault.env << 'EOF'
|
||||||
|
CVAULT_SECRET=<generate: openssl rand -hex 32>
|
||||||
|
CVAULT_SECURE_COOKIES=true
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
NODE_ENV=production
|
||||||
|
EOF
|
||||||
|
chmod 600 /etc/cvault.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Build the App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/CVault/vault
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. systemd Service
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/cvault.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=CVault Next.js application
|
||||||
|
After=network.target redis.service
|
||||||
|
Requires=redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/srv/CVault/vault
|
||||||
|
EnvironmentFile=/etc/cvault.env
|
||||||
|
ExecStart=/usr/bin/npm start -- -H 127.0.0.1 -p 3005
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable cvault.service
|
||||||
|
systemctl start cvault.service
|
||||||
|
systemctl status cvault.service --no-pager -l
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Nginx + SSL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Obtain certificate first
|
||||||
|
certbot certonly --nginx -d snp.coaxys.ir
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `/etc/nginx/sites-available/cvault`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name snp.coaxys.ir;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name snp.coaxys.ir;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/snp.coaxys.ir/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/snp.coaxys.ir/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
client_max_body_size 2m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3005;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ln -s /etc/nginx/sites-available/cvault /etc/nginx/sites-enabled/cvault
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://snp.coaxys.ir/api/auth/me | jq . # should return 401
|
||||||
|
redis-cli ping # PONG
|
||||||
|
systemctl status cvault.service --no-pager -l
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. File Permissions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# App directory owned by www-data so the service can write data/db.json
|
||||||
|
chown -R www-data:www-data /srv/CVault/vault
|
||||||
|
chmod -R 755 /srv/CVault/vault
|
||||||
|
chmod 700 /srv/CVault/vault/data # restrict db access
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL Renewal (auto via systemd timer)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status certbot.timer # should be active
|
||||||
|
# Manual renewal test:
|
||||||
|
certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Done
|
||||||
|
- Full frontend with all routes
|
||||||
|
- Code + Text snippet creation toggle
|
||||||
|
- CodeMirror editor with 13 language extensions
|
||||||
|
- Privacy toggle (secret link / password-protected)
|
||||||
|
- Expiration options (never, 1h, 1d, 1w)
|
||||||
|
- Tag input
|
||||||
|
- Snippet library with grid/list view + search
|
||||||
|
- View snippet with copy, share, edit, delete
|
||||||
|
- Password gate modal
|
||||||
|
- Shared snippet minimal view
|
||||||
|
- Dark/light theme (green/amber palette, zero purple)
|
||||||
|
- Footer with Coaxys + arsamsabbagh.ir link
|
||||||
|
- Responsive on mobile/tablet/desktop
|
||||||
|
- Framer Motion animations (visible-then-animate pattern)
|
||||||
|
- All fonts/resources served locally (no CDN)
|
||||||
|
- Production build + nginx proxy (HTTPS, snp.coaxys.ir)
|
||||||
|
- systemd service enabled for boot
|
||||||
|
- No demo account or demo snippets seeded in production
|
||||||
|
- `/raw/[id]` plain-text endpoint for curl/scripts
|
||||||
|
- CodeMirror theme selector — 6 themes, persisted to localStorage
|
||||||
|
- Inline share URL bar with copy button on owner view
|
||||||
|
- Live expiration countdown on cards, owner view, and shared view
|
||||||
|
- `lib/expiry-format.ts` — shared expiry formatting utility
|
||||||
|
|
||||||
|
## Backend Status
|
||||||
|
- Local username/password authentication with HTTP-only session cookie
|
||||||
|
- Fully local CAPTCHA for login and registration, stored in Redis
|
||||||
|
- Redis-backed session storage, CAPTCHA storage, and rate limits
|
||||||
|
- Server-side hashed user and snippet passwords
|
||||||
|
- Persistent server datastore in `data/db.json`
|
||||||
|
- API endpoints for snippet CRUD and shared snippet access
|
||||||
|
- Server-side password verification for shared snippets
|
||||||
|
- Server-side expiration enforcement
|
||||||
|
|
||||||
|
## What's Remaining
|
||||||
|
- PostgreSQL adapter if this needs multi-instance deployment
|
||||||
|
- Redis persistence hardening/monitoring if sessions must survive Redis restarts
|
||||||
|
- Real-time collaboration
|
||||||
|
- Comments on snippets
|
||||||
|
- Embed widgets for shared snippets
|
||||||
|
- Snippet templates
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,162 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { createHmac, randomInt, randomUUID, timingSafeEqual } from "crypto";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { readDb } from "@/lib/server-db";
|
||||||
|
import { getRedis } from "@/lib/server-redis";
|
||||||
|
import { User } from "@/lib/types";
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "cvault_session";
|
||||||
|
const CAPTCHA_COOKIE = "cvault_captcha";
|
||||||
|
const SESSION_MAX_AGE = 60 * 60 * 24 * 30;
|
||||||
|
const CAPTCHA_MAX_AGE = 60 * 10;
|
||||||
|
const APP_SECRET = process.env.CVAULT_SECRET ?? "cvault-local-dev-secret";
|
||||||
|
const SECURE_COOKIES = process.env.CVAULT_SECURE_COOKIES === "true";
|
||||||
|
|
||||||
|
const memorySessions = new Map<string, { userId: string; expiresAt: number }>();
|
||||||
|
const memoryCaptchas = new Map<string, { answer: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
function sign(value: string): string {
|
||||||
|
return createHmac("sha256", APP_SECRET).update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCompare(left: string, right: string): boolean {
|
||||||
|
const a = Buffer.from(left);
|
||||||
|
const b = Buffer.from(right);
|
||||||
|
return a.length === b.length && timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
export async function createSession(userId: string): Promise<string> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const value = sessionId;
|
||||||
|
const token = `${value}:${sign(value)}`;
|
||||||
|
const redis = await getRedis();
|
||||||
|
|
||||||
|
if (redis) {
|
||||||
|
await redis.set(`cvault:session:${sessionId}`, userId, { EX: SESSION_MAX_AGE });
|
||||||
|
} else {
|
||||||
|
memorySessions.set(sessionId, {
|
||||||
|
userId,
|
||||||
|
expiresAt: Date.now() + SESSION_MAX_AGE * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieStore.set(SESSION_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: SECURE_COOKIES,
|
||||||
|
path: "/",
|
||||||
|
maxAge: SESSION_MAX_AGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSession(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||||
|
const sessionId = parseSignedToken(token);
|
||||||
|
const redis = await getRedis();
|
||||||
|
|
||||||
|
if (sessionId && redis) {
|
||||||
|
await redis.del(`cvault:session:${sessionId}`);
|
||||||
|
} else if (sessionId) {
|
||||||
|
memorySessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieStore.delete(SESSION_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCaptchaChallenge(): Promise<string> {
|
||||||
|
const left = randomInt(2, 13);
|
||||||
|
const right = randomInt(2, 13);
|
||||||
|
const operator = randomInt(0, 2) === 0 ? "+" : "-";
|
||||||
|
const answer = operator === "+" ? left + right : left - right;
|
||||||
|
const nonce = randomUUID();
|
||||||
|
const value = nonce;
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const redis = await getRedis();
|
||||||
|
|
||||||
|
if (redis) {
|
||||||
|
await redis.set(`cvault:captcha:${nonce}`, String(answer), { EX: CAPTCHA_MAX_AGE });
|
||||||
|
} else {
|
||||||
|
memoryCaptchas.set(nonce, {
|
||||||
|
answer: String(answer),
|
||||||
|
expiresAt: Date.now() + CAPTCHA_MAX_AGE * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieStore.set(CAPTCHA_COOKIE, `${nonce}:${sign(value)}`, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: SECURE_COOKIES,
|
||||||
|
path: "/",
|
||||||
|
maxAge: CAPTCHA_MAX_AGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${left} ${operator} ${right}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCaptcha(answer: string): Promise<boolean> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(CAPTCHA_COOKIE)?.value;
|
||||||
|
cookieStore.delete(CAPTCHA_COOKIE);
|
||||||
|
const nonce = parseSignedToken(token);
|
||||||
|
if (!nonce) return false;
|
||||||
|
|
||||||
|
const redis = await getRedis();
|
||||||
|
let expected: string | null | undefined;
|
||||||
|
|
||||||
|
if (redis) {
|
||||||
|
const key = `cvault:captcha:${nonce}`;
|
||||||
|
expected = await redis.get(key);
|
||||||
|
if (expected) await redis.del(key);
|
||||||
|
} else {
|
||||||
|
const stored = memoryCaptchas.get(nonce);
|
||||||
|
memoryCaptchas.delete(nonce);
|
||||||
|
expected = stored && stored.expiresAt > Date.now() ? stored.answer : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expected === answer.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser(): Promise<User | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||||
|
const sessionId = parseSignedToken(token);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
|
const redis = await getRedis();
|
||||||
|
let userId: string | null | undefined;
|
||||||
|
|
||||||
|
if (redis) {
|
||||||
|
userId = await redis.get(`cvault:session:${sessionId}`);
|
||||||
|
} else {
|
||||||
|
const session = memorySessions.get(sessionId);
|
||||||
|
if (session && session.expiresAt <= Date.now()) memorySessions.delete(sessionId);
|
||||||
|
userId = session && session.expiresAt > Date.now() ? session.userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
const db = await readDb();
|
||||||
|
const user = db.users.find((candidate) => candidate.id === userId);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSignedToken(token?: string): string | null {
|
||||||
|
const parts = token?.split(":") ?? [];
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
|
||||||
|
const [value, signature] = parts;
|
||||||
|
if (!value || !signature) return null;
|
||||||
|
if (!safeCompare(signature, sign(value))) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Snippet } from "./types";
|
||||||
|
import { MOCK_SNIPPETS } from "./mock-data";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "vault_snippets";
|
||||||
|
|
||||||
|
export function getSnippets(): Snippet[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) return JSON.parse(stored);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSnippet(snippet: Snippet): void {
|
||||||
|
const snippets = getSnippets();
|
||||||
|
const idx = snippets.findIndex((s) => s.id === snippet.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
snippets[idx] = snippet;
|
||||||
|
} else {
|
||||||
|
snippets.unshift(snippet);
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(snippets));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSnippet(id: string): Snippet | undefined {
|
||||||
|
return getSnippets().find((s) => s.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSnippet(id: string): void {
|
||||||
|
const snippets = getSnippets().filter((s) => s.id !== id);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(snippets));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initMockData(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (localStorage.getItem(STORAGE_KEY)) return;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(MOCK_SNIPPETS));
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export type PrivacyType = "secret" | "password";
|
||||||
|
|
||||||
|
export type Expiration = "never" | "1h" | "1d" | "1w";
|
||||||
|
|
||||||
|
export interface Snippet {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
code: string;
|
||||||
|
language: string;
|
||||||
|
privacy: PrivacyType;
|
||||||
|
expiration: Expiration;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LANGUAGES = [
|
||||||
|
"javascript",
|
||||||
|
"typescript",
|
||||||
|
"python",
|
||||||
|
"html",
|
||||||
|
"css",
|
||||||
|
"json",
|
||||||
|
"markdown",
|
||||||
|
"rust",
|
||||||
|
"go",
|
||||||
|
"java",
|
||||||
|
"cpp",
|
||||||
|
"xml",
|
||||||
|
"php",
|
||||||
|
"plaintext",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const EXPIRATION_OPTIONS: { value: Expiration; label: string }[] = [
|
||||||
|
{ value: "never", label: "Never" },
|
||||||
|
{ value: "1h", label: "1 hour" },
|
||||||
|
{ value: "1d", label: "1 day" },
|
||||||
|
{ value: "1w", label: "1 week" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user