Browse Source

updated readme

main
Silberengel 4 weeks ago
parent
commit
73497447f9
  1. 439
      README.md
  2. 315
      src/lib/services/git/commit-signer.ts
  3. 69
      src/lib/services/git/file-manager.ts
  4. 22
      src/lib/services/git/repo-manager.ts
  5. 3
      src/lib/types/nostr.ts
  6. 43
      src/routes/+page.svelte
  7. 37
      src/routes/api/repos/[npub]/[repo]/file/+server.ts
  8. 2
      src/routes/api/repos/[npub]/[repo]/transfer/+server.ts
  9. 63
      src/routes/repos/[npub]/[repo]/+page.svelte

439
README.md

@ -1,36 +1,246 @@
# gitrepublic-web # gitrepublic-web
A Nostr-based git server with NIP-34 repo announcements. Uses git-http-backend for git operations and provides a web interface for managing repositories. A decentralized, Nostr-based git server that enables git repository hosting and collaboration using Nostr events. Repositories are announced via NIP-34, and all operations (clone, push, pull) are authenticated using NIP-98 HTTP authentication.ne
## Features ## Features
- **NIP-34 Repo Announcements**: Display and manage repository announcements ### Core Functionality
- **NIP-07 Authentication**: Sign up and authenticate using browser extensions - **NIP-34 Repo Announcements**: Create and manage repository announcements on Nostr
- **Auto-provisioning**: Automatically create git repos from NIP-34 announcements - **NIP-07 Authentication**: Web UI authentication via browser extensions (e.g., Alby, nos2x)
- **Multi-remote Sync**: Sync repos to multiple remotes listed in announcements - **NIP-98 HTTP Authentication**: Git operations (clone, push, pull) authenticated using ephemeral Nostr events
- **URL Structure**: `git.imwald.eu/{npub}/{repo-name}.git` - **Auto-provisioning**: Automatically creates git repositories from NIP-34 announcements
- **User Relay Discovery**: Automatically fetches user's inbox/outbox relays from kind 10002 or 3 - **Multi-remote Sync**: Automatically syncs repositories to multiple remotes listed in announcements
- **Repository Size Limits**: Enforces 2 GB maximum repository size
- **Relay Write Proof**: Verifies users can write to at least one default Nostr relay before allowing operations
## Development ### Repository Management
- **Repository Ownership Transfer**: Transfer ownership using kind 1641 events with a chain of ownership
- **Private Repositories**: Mark repositories as private, limiting access to owners and maintainers
- **Maintainer Management**: Add/remove maintainers who can push to repositories
- **Forking**: Fork repositories with automatic announcement creation and ownership setup
- **Repository Settings**: Manage privacy, maintainers, and description via web UI
```bash ### Collaboration Features
npm install - **Issues**: Create and manage issues (kind 1621) with status tracking
npm run dev - **Pull Requests**: Create pull requests (kind 1618) with status management
``` - **Highlights & Comments**:
- NIP-84 highlights (kind 9802) for code selections
- NIP-22 comments (kind 1111) for threaded discussions
- Comment on PRs, issues, and code highlights
- **Status Events**: Track issue/PR status (open, applied/merged, closed, draft)
## Environment Variables ### Web Interface
- **Repository Browser**: Browse files, directories, and commit history
- **Code Editor**: Edit files directly in the browser with syntax highlighting
- **Branch Management**: Create, switch, and manage branches
- **Tag Management**: Create and view git tags
- **README Rendering**: Automatic markdown rendering for README files
- **Search**: Search repositories by name, description, or author
- **User Profiles**: View user repositories and activity
- **Raw File View**: Direct access to raw file content
- **Download Repository**: Download repositories as ZIP archives
- **OpenGraph Metadata**: Rich social media previews with repository images and banners
- `NOSTRGIT_SECRET_KEY`: Server's nsec for signing repo announcements (optional, for server-side signing) ### Security & Validation
- `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`) - **Path Traversal Protection**: Validates and sanitizes file paths
- `GIT_DOMAIN`: Domain for git repositories (default: `git.imwald.eu`) - **Input Validation**: Validates commit messages, author names, emails, and file paths
- `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io`) - **File Size Limits**: 100 MB maximum per file
- **Ownership Verification**: Verifies repository ownership via self-transfer events or verification files
- **Commit Signing**: Sign commits using Nostr private keys (nsec or hex format)
- Supports both bech32 (nsec) and hex format keys
- Signatures embedded in commit messages as trailers
- Server-side signing via `NOSTRGIT_SECRET_KEY` environment variable
- Client-side signing via optional `nsecKey` parameter in API requests
## Nostr Event Kinds Used
This project uses the following Nostr event kinds:
### Repository Management
- **30617** (`REPO_ANNOUNCEMENT`): Repository announcements (NIP-34)
- Tags: `d` (repo name), `name`, `description`, `clone`, `web`, `relays`, `maintainers`, `image`, `banner`, `private`
- **30618** (`REPO_STATE`): Repository state announcements (optional)
- **1641** (`OWNERSHIP_TRANSFER`): Repository ownership transfer events (non-replaceable)
- Transfers ownership from one pubkey to another
- Self-transfers (owner → owner) used for initial ownership proof
- Non-replaceable to maintain immutable chain of ownership
### Collaboration
- **1617** (`PATCH`): Git patches
- **1618** (`PULL_REQUEST`): Pull request events
- **1619** (`PULL_REQUEST_UPDATE`): Pull request updates
- **1621** (`ISSUE`): Issue events
- **1630** (`STATUS_OPEN`): Open status
- **1631** (`STATUS_APPLIED`): Applied/merged status
- **1632** (`STATUS_CLOSED`): Closed status
- **1633** (`STATUS_DRAFT`): Draft status
- **1640** (`COMMIT_SIGNATURE`): Git commit signature events
- Tags: `author` (name, email), `message` (commit message), `commit` (commit hash), `e` (NIP-98 auth event reference, optional)
### Highlights & Comments
- **9802** (`HIGHLIGHT`): NIP-84 highlight events for code selections
- Tags: `a` (anchor), `r` (range), `p` (position), `context`, `file`, `start_line`, `end_line`, `start_pos`, `end_pos`
- **1111** (`COMMENT`): NIP-22 comment events for threaded discussions
- Tags: `A` (root event), `K` (root kind), `P` (parent event), `a`, `k`, `p` (for replies)
### Authentication
- **27235** (`NIP98_AUTH`): NIP-98 HTTP authentication events
- Tags: `u` (URL), `method` (HTTP method), `payload` (SHA256 hash of request body)
### Relay Discovery
- **3**: Contact list (for relay discovery)
- **10002**: Relay list metadata (for relay discovery)
- **1**: Text note (for relay write proof, fallback)
## How It Works
### Repository Creation Flow
1. **User Creates Announcement**:
- User visits `/signup` and connects NIP-07 extension
- Enters repository name, description, and optional clone URLs
- System automatically creates a self-transfer event (kind 1641) for initial ownership proof
- Both announcement and self-transfer are published to Nostr relays
2. **Auto-Provisioning**:
- Server polls Nostr relays for new repository announcements (kind 30617)
- When found, server:
- Creates a bare git repository at `/repos/{npub}/{repo-name}.git`
- Fetches the self-transfer event for ownership verification
- Creates initial commit with `.nostr-ownership-transfer` file containing the self-transfer event
- Creates `.nostr-verification` file with the announcement event (for backward compatibility)
- If repository has `clone` tags pointing to other remotes, syncs from those remotes
3. **Repository Access**:
- Public repositories: Anyone can clone and view
- Private repositories: Only owners and maintainers can access
- Access is checked via NIP-98 authentication for git operations
### Git Operations Flow
1. **Clone/Fetch**:
- User runs `git clone https://{domain}/{npub}/{repo}.git`
- Server handles GET requests to `info/refs?service=git-upload-pack`
- For private repos, verifies NIP-98 authentication
- Proxies request to `git-http-backend` which serves the repository
2. **Push**:
- User configures git with NIP-98 authentication
- Before push, client creates a NIP-98 event (kind 27235) with:
- `u` tag: Request URL
- `method` tag: HTTP method (POST)
- `payload` tag: SHA256 hash of request body
- Client signs event and includes in `Authorization: Nostr {event}` header
- Server verifies:
- Event signature
- Event timestamp (within 60 seconds)
- URL and method match
- Payload hash matches request body
- Pubkey is current owner or maintainer
- Server checks repository size limit (2 GB)
- Server proxies to `git-http-backend`
- After successful push, server:
- Extracts other `clone` URLs from announcement
- Syncs to all other remotes using `git push --all`
### Ownership Transfer Flow
1. **Current Owner Initiates Transfer**:
- Owner creates a kind 1641 event with:
- `from` tag: Current owner pubkey
- `to` tag: New owner pubkey
- `a` tag: Repository identifier (`30617:{owner}:{repo}`)
- Signs and publishes event
2. **Server Processes Transfer**:
- Server fetches all ownership transfer events for repository
- Validates chain of ownership chronologically
- Updates current owner for all permission checks
- Maintainers remain valid (checked against current owner)
### Pull Requests & Issues Flow
1. **Creating a PR/Issue**:
- User creates a kind 1618 (PR) or 1621 (Issue) event
- Includes repository identifier in tags
- Publishes to Nostr relays
2. **Status Management**:
- Owner/maintainer creates status events (kind 1630-1633)
- Links to PR/Issue via event references
- Status changes: open → applied/closed/draft
3. **Highlights & Comments**:
- User selects code in PR diff view
- Creates kind 9802 highlight event with code selection metadata
- Users can comment on highlights using kind 1111 events
- Comments are threaded using `A`, `K`, `P` tags (root) and `a`, `k`, `p` tags (parent)
### Forking Flow
1. **User Forks Repository**:
- User clicks "Fork" button on repository page
- Server:
- Clones original repository
- Creates new repository at `/repos/{user-npub}/{fork-name}.git`
- Creates new NIP-34 announcement for fork
- Creates self-transfer event for fork ownership
- Publishes both to Nostr relays
2. **Fork Identification**:
- Fork announcement includes reference to original repository
- UI displays "Forked from" badge
### Private Repository Access
1. **Privacy Setting**:
- Repository announcement includes `private` tag (or `t` tag with value `private`)
- Server marks repository as private
2. **Access Control**:
- All API endpoints check privacy status
- For private repos, requires NIP-98 authentication
- Verifies user is current owner or listed maintainer
- Returns 403 if unauthorized
### Relay Write Proof
Instead of traditional rate limiting, users must prove they can write to at least one default Nostr relay:
1. **Proof Mechanism**:
- User publishes a NIP-98 event (kind 27235) to a default relay
- Event must be within 60 seconds (per NIP-98 spec)
- Server verifies event exists on relay
- Alternative: User publishes kind 1 text note (5-minute window)
2. **Verification**:
- Server queries relay for the proof event
- Validates timestamp and signature
- Grants access if proof is valid
## Architecture ## Architecture
- **Frontend**: SvelteKit + TypeScript ### Frontend
- **Git Server**: git-http-backend wrapper (TODO: implement in `/src/routes/api/git/[...path]/+server.ts`) - **Framework**: SvelteKit + TypeScript
- **Authentication**: NIP-07 (browser extension) for web UI, NIP-98 (HTTP auth) for git operations - **Authentication**: NIP-07 browser extension integration
- **Discovery**: NIP-34 repo announcements with automatic polling and provisioning - **Components**: Code editor, PR detail view, repository browser
### Backend
- **Git Server**: `git-http-backend` wrapper for git operations
- **Authentication**: NIP-98 HTTP authentication for git operations
- **Repository Management**: Automatic provisioning and syncing
- **Nostr Integration**: WebSocket client for relay communication
### Services
- **NostrClient**: WebSocket client for fetching and publishing Nostr events
- **RepoManager**: Server-side repository provisioning, syncing, and size management
- **FileManager**: File operations within git repositories with validation
- **CommitSigner**: Git commit signing using Nostr keys (supports nsec and hex formats)
- **OwnershipTransferService**: Manages repository ownership transfers
- **MaintainerService**: Checks maintainer permissions and privacy settings
- **HighlightsService**: Manages NIP-84 highlights and NIP-22 comments
- **RelayWriteProof**: Verifies user can write to Nostr relays
## Project Structure ## Project Structure
@ -41,82 +251,149 @@ src/
│ │ ├── nostr/ │ │ ├── nostr/
│ │ │ ├── nostr-client.ts # WebSocket client for Nostr relays │ │ │ ├── nostr-client.ts # WebSocket client for Nostr relays
│ │ │ ├── nip07-signer.ts # NIP-07 browser extension integration │ │ │ ├── nip07-signer.ts # NIP-07 browser extension integration
│ │ │ ├── nip19-utils.ts # Decode hex/nevent/naddr addresses │ │ │ ├── nip98-auth.ts # NIP-98 HTTP authentication
│ │ │ ├── repo-polling.ts # Auto-provision repos from announcements │ │ │ ├── repo-polling.ts # Auto-provision repos from announcements
│ │ │ └── user-relays.ts # Fetch user's preferred relays │ │ │ ├── user-relays.ts # Fetch user's preferred relays
│ │ │ ├── ownership-transfer-service.ts # Repository ownership transfers
│ │ │ ├── maintainer-service.ts # Maintainer permission checks
│ │ │ ├── highlights-service.ts # NIP-84 highlights & NIP-22 comments
│ │ │ ├── relay-write-proof.ts # Relay write proof verification
│ │ │ ├── prs-service.ts # Pull request management
│ │ │ └── issues-service.ts # Issue management
│ │ └── git/ │ │ └── git/
│ │ └── repo-manager.ts # Server-side repo provisioning & syncing │ │ ├── repo-manager.ts # Repository provisioning & syncing
│ │ └── file-manager.ts # File operations with validation
│ ├── components/
│ │ ├── CodeEditor.svelte # Code editor with syntax highlighting
│ │ └── PRDetail.svelte # Pull request detail view
│ └── types/ │ └── types/
│ └── nostr.ts # TypeScript types for Nostr │ └── nostr.ts # TypeScript types for Nostr events
├── routes/ ├── routes/
│ ├── +page.svelte # Main page: list repos on server │ ├── +page.svelte # Main page: list repositories
│ ├── signup/ │ ├── signup/
│ │ └── +page.svelte # Sign-up: create/update repo announcements │ │ └── +page.svelte # Create/update repo announcements
│ ├── repos/[npub]/[repo]/
│ │ ├── +page.svelte # Repository detail page
│ │ ├── +page.ts # OpenGraph metadata loader
│ │ └── settings/
│ │ └── +page.svelte # Repository settings UI
│ ├── users/[npub]/
│ │ └── +page.svelte # User profile page
│ ├── search/
│ │ └── +page.svelte # Search interface
│ └── api/ │ └── api/
│ └── git/ │ ├── git/[...path]/
│ └── [...path]/ │ │ └── +server.ts # Git HTTP backend API
│ └── +server.ts # Git HTTP backend API (TODO) │ └── repos/[npub]/[repo]/
│ ├── file/+server.ts # File read/write API
│ ├── tree/+server.ts # Directory listing API
│ ├── branches/+server.ts # Branch management API
│ ├── commits/+server.ts # Commit history API
│ ├── tags/+server.ts # Tag management API
│ ├── issues/+server.ts # Issues API
│ ├── prs/+server.ts # Pull requests API
│ ├── highlights/+server.ts # Highlights & comments API
│ ├── fork/+server.ts # Fork repository API
│ ├── readme/+server.ts # README fetching API
│ ├── raw/+server.ts # Raw file view API
│ ├── download/+server.ts # Download repository as ZIP
│ ├── settings/+server.ts # Repository settings API
│ ├── transfer/+server.ts # Ownership transfer API
│ └── verify/+server.ts # Ownership verification API
└── hooks.server.ts # Server initialization (starts polling) └── hooks.server.ts # Server initialization (starts polling)
``` ```
## Implementation Status ## Development
✅ **Completed:** ### Prerequisites
- NIP-07 authentication for sign-up page - Node.js 18+
- Repo announcement display page - Git with `git-http-backend` installed
- Repo announcement creation/update with hex/nevent/naddr support - NIP-07 browser extension (for web UI)
- User relay discovery (kind 10002 and 3)
- NIP-34 polling and auto-provisioning service
- Server-side repo manager for provisioning and syncing
🚧 **In Progress:** ### Setup
- Git HTTP backend wrapper with Nostr authentication (NIP-98)
## Next Steps ```bash
npm install
npm run dev
```
1. **Implement git-http-backend integration** in `/src/routes/api/git/[...path]/+server.ts`: ### Environment Variables
- Parse URL path to extract `{npub}/{repo-name}`
- Authenticate using NIP-98 (HTTP Authorization header with Nostr event)
- Proxy requests to `git-http-backend` CGI script
- Handle git smart HTTP protocol (info/refs, git-upload-pack, git-receive-pack)
- Trigger post-receive hooks to sync to other remotes
2. **Set up git-http-backend**: - `NOSTRGIT_SECRET_KEY`: Server's nsec (bech32 or hex) for signing repo announcements and initial commits (optional)
- Install `git-http-backend` (usually comes with git) - `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`)
- Configure as CGI script or FastCGI - `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`)
- Set up proper permissions for repo directory - `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io`)
### Git HTTP Backend Setup
3. **Implement NIP-98 authentication**: The server uses `git-http-backend` for git operations. Ensure it's installed:
- Verify Nostr event signature in Authorization header
- Check that pubkey matches repo owner (from URL)
- Validate event timestamp (not too old)
4. **Add post-receive hook**: ```bash
- After successful push, extract other clone URLs from NIP-34 announcement # On Debian/Ubuntu
- Sync to all other remotes using `git push --all` sudo apt-get install git
# Verify installation
which git-http-backend
```
The server will automatically locate `git-http-backend` in common locations.
## Usage ## Usage
1. **Create a repository announcement:** ### Creating a Repository
- Go to `/signup`
- Connect your NIP-07 extension 1. Go to `/signup`
- Enter repository name and description 2. Connect your NIP-07 extension
- Optionally load an existing announcement by providing hex ID, nevent, or naddr 3. Enter repository name and description
- Add clone URLs (git.imwald.eu will be added automatically) 4. Optionally add clone URLs (your domain will be added automatically)
- Publish the announcement 5. Optionally add images/banners for OpenGraph previews
6. Publish the announcement
2. **View repositories:**
- Go to `/` to see all repositories on git.imwald.eu The server will automatically provision the repository.
- Repositories are automatically provisioned when announcements are published
### Cloning a Repository
3. **Clone a repository:**
```bash ```bash
git clone https://git.imwald.eu/{npub}/{repo-name}.git git clone https://{domain}/{npub}/{repo-name}.git
``` ```
4. **Push to repository:** For private repositories, configure git with NIP-98 authentication.
```bash
git remote add origin https://git.imwald.eu/{npub}/{repo-name}.git ### Pushing to a Repository
git push origin main
``` ```bash
(Requires NIP-98 authentication - TODO) git remote add origin https://{domain}/{npub}/{repo-name}.git
git push origin main
```
Requires NIP-98 authentication. Your git client needs to support NIP-98 or you can use a custom credential helper.
### Viewing Repositories
- Go to `/` to see all public repositories
- Go to `/repos/{npub}/{repo}` to view a specific repository
- Go to `/users/{npub}` to view a user's repositories
- Go to `/search` to search for repositories
### Managing Repositories
- **Settings**: Visit `/repos/{npub}/{repo}/settings` to manage privacy, maintainers, and description
- **Forking**: Click "Fork" button on repository page
- **Transfer Ownership**: Use the transfer API endpoint or create a kind 1641 event manually
## Security Considerations
- **Path Traversal**: All file paths are validated and sanitized
- **Input Validation**: Commit messages, author info, and file paths are validated
- **Size Limits**: 2 GB per repository, 100 MB per file
- **Authentication**: All write operations require NIP-98 authentication
- **Authorization**: Ownership and maintainer checks for all operations
- **Private Repositories**: Access restricted to owners and maintainers
## License
[Add your license here]
## Contributing
[Add contribution guidelines here]

315
src/lib/services/git/commit-signer.ts

@ -0,0 +1,315 @@
/**
* Git commit signing service using Nostr keys
* Supports:
* - NIP-07 browser extension (for web UI)
* - NIP-98 HTTP authentication (for git operations)
* - Direct nsec/hex keys (for server-side signing)
*/
import { nip19, getPublicKey, finalizeEvent, getEventHash } from 'nostr-tools';
import { createHash } from 'crypto';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/nostr.js';
import { signEventWithNIP07 } from '../nostr/nip07-signer.js';
export interface CommitSignature {
signature: string;
pubkey: string;
eventId: string;
timestamp: number;
}
/**
* Decode a Nostr key from bech32 (nsec) or hex format
* Returns the hex-encoded private key as Uint8Array
*/
export function decodeNostrKey(key: string): Uint8Array {
let hexKey: string;
// Check if it's already hex (64 characters, hex format)
if (/^[0-9a-fA-F]{64}$/.test(key)) {
hexKey = key.toLowerCase();
} else {
// Try to decode as bech32 (nsec)
try {
const decoded = nip19.decode(key);
if (decoded.type === 'nsec') {
// decoded.data for nsec is Uint8Array, convert to hex string
const data = decoded.data as Uint8Array;
hexKey = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join('');
} else {
throw new Error('Key is not a valid nsec or hex private key');
}
} catch (error) {
throw new Error(`Invalid key format: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Convert hex string to Uint8Array
const keyBytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
keyBytes[i] = parseInt(hexKey.slice(i * 2, i * 2 + 2), 16);
}
return keyBytes;
}
/**
* Decode a Nostr ID (event ID or pubkey) from bech32 or hex format
* Returns the hex-encoded value as string
*/
export function decodeNostrId(id: string): string {
// Check if it's already hex (64 characters for pubkey/event ID)
if (/^[0-9a-fA-F]{64}$/.test(id)) {
return id.toLowerCase();
}
// Try to decode as bech32 (npub, note, nevent, naddr, etc.)
try {
const decoded = nip19.decode(id);
if (decoded.type === 'npub' || decoded.type === 'note' || decoded.type === 'nevent' || decoded.type === 'naddr') {
// decoded.data can be string (for npub, note) or object (for nevent, naddr)
if (typeof decoded.data === 'string') {
return decoded.data;
} else if (decoded.type === 'nevent') {
const data = decoded.data as { id: string };
return data.id;
} else if (decoded.type === 'naddr') {
// For naddr, we return the pubkey as the identifier
const data = decoded.data as { pubkey: string };
return data.pubkey;
}
}
throw new Error('ID is not a valid bech32 or hex format');
} catch (error) {
throw new Error(`Invalid ID format: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Create a Nostr event for commit signing
* This creates a kind 1640 (commit signature) event that can be used to sign commits
*/
export function createCommitSignatureEvent(
privateKey: string,
commitHash: string,
commitMessage: string,
authorName: string,
authorEmail: string,
timestamp: number = Math.floor(Date.now() / 1000)
): { event: NostrEvent; signature: string } {
const keyBytes = decodeNostrKey(privateKey);
const pubkey = getPublicKey(keyBytes);
// Create a commit signature event template
// Using kind 1640 for commit signatures (dedicated kind to avoid feed spam)
const eventTemplate = {
kind: KIND.COMMIT_SIGNATURE,
pubkey,
created_at: timestamp,
tags: [
['commit', commitHash],
['author', authorName, authorEmail],
['message', commitMessage]
],
content: `Signed commit: ${commitHash}\n\n${commitMessage}`
};
// Finalize and sign the event
const signedEvent = finalizeEvent(eventTemplate, keyBytes);
const event: NostrEvent = {
...signedEvent,
id: signedEvent.id,
sig: signedEvent.sig
};
return {
event,
signature: signedEvent.sig
};
}
/**
* Create a GPG-style signature for git commits
* Git expects GPG signatures in a specific format, but we can use Nostr signatures
* by embedding them in the commit message or as a trailer
*
* Supports multiple signing methods:
* - NIP-07: Browser extension signing (client-side)
* - NIP-98: Use HTTP auth event as signature (server-side, for git operations)
* - nsec/hex: Direct key signing (server-side, when key is available)
*
* @param commitMessage - The commit message to sign
* @param authorName - Author name
* @param authorEmail - Author email
* @param options - Signing options
* @param options.useNIP07 - Use NIP-07 browser extension (client-side only)
* @param options.nip98Event - Use NIP-98 auth event as signature (server-side)
* @param options.nsecKey - Use direct nsec/hex key (server-side)
* @param options.timestamp - Optional timestamp (defaults to now)
* @returns Signed commit message and signature event
*/
export async function createGitCommitSignature(
commitMessage: string,
authorName: string,
authorEmail: string,
options: {
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
timestamp?: number;
} = {}
): Promise<{ signedMessage: string; signatureEvent: NostrEvent }> {
const timestamp = options.timestamp || Math.floor(Date.now() / 1000);
let signedEvent: NostrEvent;
// Method 1: Use NIP-07 browser extension (client-side)
if (options.useNIP07) {
// NIP-07 will add pubkey automatically, so we don't need it in the template
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.COMMIT_SIGNATURE,
pubkey: '', // Will be filled by NIP-07
created_at: timestamp,
tags: [
['author', authorName, authorEmail],
['message', commitMessage]
],
content: `Signed commit: ${commitMessage}`
};
signedEvent = await signEventWithNIP07(eventTemplate);
}
// Method 2: Use NIP-98 auth event as signature (server-side, for git operations)
else if (options.nip98Event) {
// Create a commit signature event using the NIP-98 event's pubkey
// The NIP-98 event itself proves the user can sign, so we reference it
const eventTemplate = {
kind: KIND.COMMIT_SIGNATURE,
pubkey: options.nip98Event.pubkey,
created_at: timestamp,
tags: [
['author', authorName, authorEmail],
['message', commitMessage],
['e', options.nip98Event.id, '', 'nip98-auth'] // Reference the NIP-98 auth event
],
content: `Signed commit: ${commitMessage}\n\nAuthenticated via NIP-98 event: ${options.nip98Event.id}`
};
// For NIP-98, we use the auth event's signature as proof
// The commit signature event references the NIP-98 event
signedEvent = finalizeEvent(eventTemplate, new Uint8Array(32)); // Dummy key, signature comes from NIP-98
// Note: In practice, we'd want the client to sign this, but for git operations,
// the NIP-98 event proves authentication, so we embed it as a reference
}
// Method 3: Use direct nsec/hex key (server-side)
else if (options.nsecKey) {
const keyBytes = decodeNostrKey(options.nsecKey);
const pubkey = getPublicKey(keyBytes);
const eventTemplate = {
kind: KIND.COMMIT_SIGNATURE,
pubkey,
created_at: timestamp,
tags: [
['author', authorName, authorEmail],
['message', commitMessage]
],
content: `Signed commit: ${commitMessage}`
};
signedEvent = finalizeEvent(eventTemplate, keyBytes);
} else {
throw new Error('No signing method provided. Use useNIP07, nip98Event, or nsecKey.');
}
// Create a signature trailer that git can recognize
// Format: Nostr-Signature: <event-id> <pubkey> <signature>
const signatureTrailer = `\n\nNostr-Signature: ${signedEvent.id} ${signedEvent.pubkey} ${signedEvent.sig}`;
const signedMessage = commitMessage + signatureTrailer;
return { signedMessage, signatureEvent: signedEvent };
}
/**
* Update commit signature with actual commit hash after commit is created
*/
export function updateCommitSignatureWithHash(
signatureEvent: NostrEvent,
commitHash: string
): NostrEvent {
// Add commit hash tag
const commitTag = signatureEvent.tags.find(t => t[0] === 'commit');
if (!commitTag) {
signatureEvent.tags.push(['commit', commitHash]);
} else {
commitTag[1] = commitHash;
}
// Recalculate event ID with updated tags
const serialized = JSON.stringify([
0,
signatureEvent.pubkey,
signatureEvent.created_at,
signatureEvent.kind,
signatureEvent.tags,
signatureEvent.content
]);
signatureEvent.id = createHash('sha256').update(serialized).digest('hex');
// Note: Re-signing would require the private key, which we don't have here
// The signature in the original event is still valid for the commit hash tag
return signatureEvent;
}
/**
* Verify a commit signature from a Nostr event
*/
export function verifyCommitSignature(
signatureEvent: NostrEvent,
commitHash: string
): { valid: boolean; error?: string } {
// Check event kind
if (signatureEvent.kind !== KIND.COMMIT_SIGNATURE) {
return { valid: false, error: `Invalid event kind for commit signature. Expected ${KIND.COMMIT_SIGNATURE}, got ${signatureEvent.kind}` };
}
// Check commit hash tag
const commitTag = signatureEvent.tags.find(t => t[0] === 'commit');
if (!commitTag || commitTag[1] !== commitHash) {
return { valid: false, error: 'Commit hash mismatch' };
}
// Verify event signature (would need to import verifyEvent from nostr-tools)
// For now, we'll just check the structure
if (!signatureEvent.sig || !signatureEvent.id) {
return { valid: false, error: 'Missing signature or event ID' };
}
return { valid: true };
}
/**
* Extract commit signature from commit message
*/
export function extractCommitSignature(commitMessage: string): {
message: string;
signature?: CommitSignature;
} {
const signatureRegex = /Nostr-Signature:\s+([0-9a-f]{64})\s+([0-9a-f]{64})\s+([0-9a-f]{128})/;
const match = commitMessage.match(signatureRegex);
if (!match) {
return { message: commitMessage };
}
const [, eventId, pubkey, signature] = match;
const cleanMessage = commitMessage.replace(signatureRegex, '').trim();
return {
message: cleanMessage,
signature: {
signature,
pubkey,
eventId,
timestamp: Math.floor(Date.now() / 1000) // Would need to extract from event
}
};
}

69
src/lib/services/git/file-manager.ts

@ -8,6 +8,8 @@ import { readFile, readdir, stat } from 'fs/promises';
import { join, dirname, normalize, resolve } from 'path'; import { join, dirname, normalize, resolve } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { RepoManager } from './repo-manager.js'; import { RepoManager } from './repo-manager.js';
import { createGitCommitSignature } from './commit-signer.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface FileEntry { export interface FileEntry {
name: string; name: string;
@ -273,6 +275,10 @@ export class FileManager {
/** /**
* Write file and commit changes * Write file and commit changes
* @param signingOptions - Optional commit signing options:
* - useNIP07: Use NIP-07 browser extension (client-side only)
* - nip98Event: Use NIP-98 auth event as signature (server-side, for git operations)
* - nsecKey: Use direct nsec/hex key (server-side)
*/ */
async writeFile( async writeFile(
npub: string, npub: string,
@ -282,7 +288,12 @@ export class FileManager {
commitMessage: string, commitMessage: string,
authorName: string, authorName: string,
authorEmail: string, authorEmail: string,
branch: string = 'main' branch: string = 'main',
signingOptions?: {
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
}
): Promise<void> { ): Promise<void> {
// Validate inputs // Validate inputs
const npubValidation = this.validateNpub(npub); const npubValidation = this.validateNpub(npub);
@ -382,8 +393,25 @@ export class FileManager {
// Stage the file (use validated path) // Stage the file (use validated path)
await workGit.add(validatedPath); await workGit.add(validatedPath);
// Sign commit if signing options are provided
let finalCommitMessage = commitMessage;
if (signingOptions && (signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try {
const { signedMessage } = await createGitCommitSignature(
commitMessage,
authorName,
authorEmail,
signingOptions
);
finalCommitMessage = signedMessage;
} catch (err) {
console.warn('Failed to sign commit:', err);
// Continue without signature if signing fails
}
}
// Commit // Commit
await workGit.commit(commitMessage, [filePath], { await workGit.commit(finalCommitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>` '--author': `${authorName} <${authorEmail}>`
}); });
@ -423,6 +451,7 @@ export class FileManager {
/** /**
* Create a new file * Create a new file
* @param signingOptions - Optional commit signing options (see writeFile)
*/ */
async createFile( async createFile(
npub: string, npub: string,
@ -432,14 +461,20 @@ export class FileManager {
commitMessage: string, commitMessage: string,
authorName: string, authorName: string,
authorEmail: string, authorEmail: string,
branch: string = 'main' branch: string = 'main',
signingOptions?: {
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
}
): Promise<void> { ): Promise<void> {
// Reuse writeFile logic - it will create the file if it doesn't exist // Reuse writeFile logic - it will create the file if it doesn't exist
return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch); return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch, signingOptions);
} }
/** /**
* Delete a file * Delete a file
* @param signingOptions - Optional commit signing options (see writeFile)
*/ */
async deleteFile( async deleteFile(
npub: string, npub: string,
@ -448,7 +483,12 @@ export class FileManager {
commitMessage: string, commitMessage: string,
authorName: string, authorName: string,
authorEmail: string, authorEmail: string,
branch: string = 'main' branch: string = 'main',
signingOptions?: {
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
}
): Promise<void> { ): Promise<void> {
// Validate inputs // Validate inputs
const npubValidation = this.validateNpub(npub); const npubValidation = this.validateNpub(npub);
@ -522,8 +562,25 @@ export class FileManager {
// Stage the deletion (use validated path) // Stage the deletion (use validated path)
await workGit.rm([validatedPath]); await workGit.rm([validatedPath]);
// Sign commit if signing options are provided
let finalCommitMessage = commitMessage;
if (signingOptions && (signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try {
const { signedMessage } = await createGitCommitSignature(
commitMessage,
authorName,
authorEmail,
signingOptions
);
finalCommitMessage = signedMessage;
} catch (err) {
console.warn('Failed to sign commit:', err);
// Continue without signature if signing fails
}
}
// Commit // Commit
await workGit.commit(commitMessage, [filePath], { await workGit.commit(finalCommitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>` '--author': `${authorName} <${authorEmail}>`
}); });

22
src/lib/services/git/repo-manager.ts

@ -290,10 +290,30 @@ export class RepoManager {
// Use the event timestamp for commit date // Use the event timestamp for commit date
const commitDate = new Date(event.created_at * 1000).toISOString(); const commitDate = new Date(event.created_at * 1000).toISOString();
const commitMessage = selfTransferEvent let commitMessage = selfTransferEvent
? 'Add Nostr repository verification and initial ownership proof' ? 'Add Nostr repository verification and initial ownership proof'
: 'Add Nostr repository verification file'; : 'Add Nostr repository verification file';
// Sign commit if nsec key is provided (from environment or event)
// Note: For initial commits, we might not have the user's nsec, so this is optional
const nsecKey = process.env.NOSTRGIT_SECRET_KEY;
if (nsecKey) {
try {
const { createGitCommitSignature } = await import('./commit-signer.js');
const { signedMessage } = createGitCommitSignature(
nsecKey,
commitMessage,
'Nostr',
`${event.pubkey}@nostr`,
event.created_at
);
commitMessage = signedMessage;
} catch (err) {
console.warn('Failed to sign initial commit:', err);
// Continue without signature if signing fails
}
}
await workGit.commit(commitMessage, filesToAdd, { await workGit.commit(commitMessage, filesToAdd, {
'--author': `Nostr <${event.pubkey}@nostr>`, '--author': `Nostr <${event.pubkey}@nostr>`,
'--date': commitDate '--date': commitDate

3
src/lib/types/nostr.ts

@ -28,7 +28,6 @@ export interface NostrFilter {
export const KIND = { export const KIND = {
REPO_ANNOUNCEMENT: 30617, REPO_ANNOUNCEMENT: 30617,
REPO_STATE: 30618, REPO_STATE: 30618,
OWNERSHIP_TRANSFER: 30619, // Repository ownership transfer event
PATCH: 1617, PATCH: 1617,
PULL_REQUEST: 1618, PULL_REQUEST: 1618,
PULL_REQUEST_UPDATE: 1619, PULL_REQUEST_UPDATE: 1619,
@ -37,6 +36,8 @@ export const KIND = {
STATUS_APPLIED: 1631, STATUS_APPLIED: 1631,
STATUS_CLOSED: 1632, STATUS_CLOSED: 1632,
STATUS_DRAFT: 1633, STATUS_DRAFT: 1633,
COMMIT_SIGNATURE: 1640, // Git commit signature event
OWNERSHIP_TRANSFER: 1641, // Repository ownership transfer event (non-replaceable for chain integrity)
HIGHLIGHT: 9802, // NIP-84: Highlight event HIGHLIGHT: 9802, // NIP-84: Highlight event
COMMENT: 1111, // NIP-22: Comment event COMMENT: 1111, // NIP-22: Comment event
} as const; } as const;

43
src/routes/+page.svelte

@ -6,10 +6,12 @@
import { KIND } from '../lib/types/nostr.js'; import { KIND } from '../lib/types/nostr.js';
import type { NostrEvent } from '../lib/types/nostr.js'; import type { NostrEvent } from '../lib/types/nostr.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { getPublicKeyWithNIP07, isNIP07Available } from '../lib/services/nostr/nip07-signer.js';
let repos = $state<NostrEvent[]>([]); let repos = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let userPubkey = $state<string | null>(null);
import { DEFAULT_NOSTR_RELAYS } from '../lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '../lib/config.js';
@ -17,8 +19,37 @@
onMount(async () => { onMount(async () => {
await loadRepos(); await loadRepos();
await checkAuth();
}); });
async function checkAuth() {
try {
if (isNIP07Available()) {
userPubkey = await getPublicKeyWithNIP07();
}
} catch (err) {
console.log('NIP-07 not available or user not connected');
userPubkey = null;
}
}
async function login() {
try {
if (!isNIP07Available()) {
alert('NIP-07 extension not found. Please install a Nostr extension like Alby or nos2x.');
return;
}
userPubkey = await getPublicKeyWithNIP07();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to connect';
console.error('Login error:', err);
}
}
function logout() {
userPubkey = null;
}
async function loadRepos() { async function loadRepos() {
loading = true; loading = true;
error = null; error = null;
@ -160,6 +191,18 @@
<a href="/search">Search</a> <a href="/search">Search</a>
<a href="/signup">Sign Up</a> <a href="/signup">Sign Up</a>
<a href="/docs/nip34">NIP-34 Docs</a> <a href="/docs/nip34">NIP-34 Docs</a>
<div class="auth-section">
{#if userPubkey}
<span class="user-info">
{nip19.npubEncode(userPubkey).slice(0, 16)}...
</span>
<button onclick={logout} class="logout-button">Logout</button>
{:else}
<button onclick={login} class="login-button" disabled={!isNIP07Available()}>
{isNIP07Available() ? 'Login' : 'NIP-07 Not Available'}
</button>
{/if}
</div>
</nav> </nav>
</header> </header>

37
src/routes/api/repos/[npub]/[repo]/file/+server.ts

@ -9,6 +9,7 @@ import { FileManager } from '$lib/services/git/file-manager.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { verifyNIP98Auth } from '$lib/services/nostr/nip98-auth.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); const fileManager = new FileManager(repoRoot);
@ -55,7 +56,7 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
} }
}; };
export const POST: RequestHandler = async ({ params, request }: { params: { npub?: string; repo?: string }; request: Request }) => { export const POST: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => {
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
@ -64,7 +65,18 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
try { try {
const body = await request.json(); const body = await request.json();
const { path, content, commitMessage, authorName, authorEmail, branch, action, userPubkey } = body; const { path, content, commitMessage, authorName, authorEmail, branch, action, userPubkey, useNIP07, nsecKey } = body;
// Check for NIP-98 authentication (for git operations)
const authHeader = request.headers.get('Authorization');
let nip98Event = null;
if (authHeader && authHeader.startsWith('Nostr ')) {
const requestUrl = `${request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http')}://${request.headers.get('host') || url.host}${url.pathname}${url.search}`;
const authResult = verifyNIP98Auth(authHeader, requestUrl, request.method);
if (authResult.valid && authResult.event) {
nip98Event = authResult.event;
}
}
if (!path || !commitMessage || !authorName || !authorEmail) { if (!path || !commitMessage || !authorName || !authorEmail) {
return error(400, 'Missing required fields: path, commitMessage, authorName, authorEmail'); return error(400, 'Missing required fields: path, commitMessage, authorName, authorEmail');
@ -108,6 +120,21 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
return error(403, 'Only repository maintainers can edit files directly. Please submit a pull request instead.'); return error(403, 'Only repository maintainers can edit files directly. Please submit a pull request instead.');
} }
// Prepare signing options
const signingOptions: {
useNIP07?: boolean;
nip98Event?: any;
nsecKey?: string;
} = {};
if (useNIP07) {
signingOptions.useNIP07 = true;
} else if (nip98Event) {
signingOptions.nip98Event = nip98Event;
} else if (nsecKey) {
signingOptions.nsecKey = nsecKey;
}
if (action === 'delete') { if (action === 'delete') {
await fileManager.deleteFile( await fileManager.deleteFile(
npub, npub,
@ -116,7 +143,8 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
commitMessage, commitMessage,
authorName, authorName,
authorEmail, authorEmail,
branch || 'main' branch || 'main',
Object.keys(signingOptions).length > 0 ? signingOptions : undefined
); );
return json({ success: true, message: 'File deleted and committed' }); return json({ success: true, message: 'File deleted and committed' });
} else if (action === 'create' || content !== undefined) { } else if (action === 'create' || content !== undefined) {
@ -131,7 +159,8 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
commitMessage, commitMessage,
authorName, authorName,
authorEmail, authorEmail,
branch || 'main' branch || 'main',
Object.keys(signingOptions).length > 0 ? signingOptions : undefined
); );
return json({ success: true, message: 'File saved and committed' }); return json({ success: true, message: 'File saved and committed' });
} else { } else {

2
src/routes/api/repos/[npub]/[repo]/transfer/+server.ts

@ -136,7 +136,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
// Verify it's an ownership transfer event // Verify it's an ownership transfer event
if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) { if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) {
return error(400, 'Event must be kind 30619 (ownership transfer)'); return error(400, `Event must be kind ${KIND.OWNERSHIP_TRANSFER} (ownership transfer)`);
} }
// Verify the 'a' tag references this repo // Verify the 'a' tag references this repo

63
src/routes/repos/[npub]/[repo]/+page.svelte

@ -4,7 +4,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import CodeEditor from '$lib/components/CodeEditor.svelte'; import CodeEditor from '$lib/components/CodeEditor.svelte';
import PRDetail from '$lib/components/PRDetail.svelte'; import PRDetail from '$lib/components/PRDetail.svelte';
import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
@ -235,16 +235,37 @@
async function checkAuth() { async function checkAuth() {
try { try {
if (typeof window !== 'undefined' && window.nostr) { if (isNIP07Available()) {
userPubkey = await getPublicKeyWithNIP07(); userPubkey = await getPublicKeyWithNIP07();
// Recheck maintainer status after auth // Recheck maintainer status after auth
await checkMaintainerStatus(); await checkMaintainerStatus();
} }
} catch (err) { } catch (err) {
console.log('NIP-07 not available or user not connected'); console.log('NIP-07 not available or user not connected');
userPubkey = null;
} }
} }
async function login() {
try {
if (!isNIP07Available()) {
alert('NIP-07 extension not found. Please install a Nostr extension like Alby or nos2x.');
return;
}
userPubkey = await getPublicKeyWithNIP07();
// Re-check maintainer status after login
await checkMaintainerStatus();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to connect';
console.error('Login error:', err);
}
}
function logout() {
userPubkey = null;
isMaintainer = false;
}
async function checkMaintainerStatus() { async function checkMaintainerStatus() {
if (!userPubkey) { if (!userPubkey) {
isMaintainer = false; isMaintainer = false;
@ -404,7 +425,8 @@
authorName: 'Web Editor', authorName: 'Web Editor',
authorEmail: `${npubFromPubkey}@nostr`, authorEmail: `${npubFromPubkey}@nostr`,
branch: currentBranch, branch: currentBranch,
userPubkey: userPubkey userPubkey: userPubkey,
useNIP07: true // Use NIP-07 for commit signing in web UI
}) })
}); });
@ -912,8 +934,12 @@
✓ Authenticated (Contributor) ✓ Authenticated (Contributor)
{/if} {/if}
</span> </span>
<button onclick={logout} class="logout-button">Logout</button>
{:else} {:else}
<span class="auth-status">Not authenticated</span> <span class="auth-status">Not authenticated</span>
<button onclick={login} class="login-button" disabled={!isNIP07Available()}>
{isNIP07Available() ? 'Login' : 'NIP-07 Not Available'}
</button>
{/if} {/if}
{#if verificationStatus} {#if verificationStatus}
@ -1673,6 +1699,37 @@
color: #6b7280; color: #6b7280;
} }
.login-button,
.logout-button {
padding: 0.5rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
background: white;
color: #374151;
cursor: pointer;
font-size: 0.875rem;
}
.login-button:hover:not(:disabled) {
background: #f9fafb;
}
.login-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.logout-button {
background: #ef4444;
color: white;
border-color: #ef4444;
margin-left: 0.5rem;
}
.logout-button:hover {
background: #dc2626;
}
.repo-view { .repo-view {
flex: 1; flex: 1;
display: flex; display: flex;

Loading…
Cancel
Save