full readme with docs, plugin guide, and setup instructions
This commit is contained in:
468
README.md
468
README.md
@@ -1,8 +1,91 @@
|
||||
# Echoboard
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/echoboard-feedback_for_everyone-F59E0B?style=for-the-badge&labelColor=161616" alt="Echoboard" />
|
||||
</p>
|
||||
|
||||
Self-hosted feedback board. Users submit feature requests and bug reports without creating an account - anonymous by default, with optional passkey registration for persistence across devices. No email required for anything.
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/license-CC0_1.0-green?style=flat-square" alt="License: CC0" />
|
||||
<img src="https://img.shields.io/badge/self--hosted-docker-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" />
|
||||
<img src="https://img.shields.io/badge/no_accounts-anonymous_first-8B5CF6?style=flat-square" alt="Anonymous first" />
|
||||
<img src="https://img.shields.io/badge/no_tracking-zero_analytics-10B981?style=flat-square" alt="No tracking" />
|
||||
<img src="https://img.shields.io/badge/accessibility-WCAG_2.2_AAA-0EA5E9?style=flat-square" alt="WCAG 2.2 AAA" />
|
||||
</p>
|
||||
|
||||
## Self-hosting with Docker
|
||||
<p align="center">
|
||||
A self-hosted feedback board where people actually own their voice.<br />
|
||||
No accounts required. No email harvesting. No surveillance.<br />
|
||||
Just people telling you what they think - on their terms.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🌍 What is Echoboard?
|
||||
|
||||
Echoboard is a feedback platform you host yourself. People visit your board, submit feature requests or bug reports, vote on what matters to them, and leave comments - all without creating an account, handing over an email address, or agreeing to be tracked.
|
||||
|
||||
It's built on a simple idea: feedback should be accessible to everyone, not just people willing to hand over personal data to yet another platform. The barrier to participation is zero. Show up, speak, leave. Your voice counts the same whether you're anonymous or not.
|
||||
|
||||
If someone wants more permanence - to keep their posts across devices or browsers - they can optionally register a passkey. No password, no email. Just a biometric or device PIN. Their identity belongs to them, stored on their own device, not in your database.
|
||||
|
||||
## 🗳️ How people use it
|
||||
|
||||
**As a visitor**, you land on a board and immediately see what others have submitted. You can vote on things you care about, leave comments, file your own feature requests or bug reports - all without signing up for anything. A browser cookie ties your activity together for the session. Close the tab and come back later, you're still you (as long as cookies persist).
|
||||
|
||||
**If you want persistence**, you can register a passkey from the settings page. This lets you keep your identity across browsers and devices. You get a username, a profile, and the ability to upload an avatar. Or if your browser doesn't support passkeys, you can generate a recovery phrase - six words you write down somewhere safe that let you get back to your account if cookies get cleared.
|
||||
|
||||
**As an admin or team member**, you manage boards, respond to feedback, track statuses, merge duplicates, and keep things organized. The admin dashboard gives you full control over every aspect of the platform without touching a terminal.
|
||||
|
||||
## ✊ Why self-host?
|
||||
|
||||
Because your community's feedback shouldn't live on someone else's server, feeding someone else's business model. When you self-host Echoboard:
|
||||
|
||||
- The data lives on your infrastructure, under your control
|
||||
- No third party reads, mines, or monetizes the feedback
|
||||
- No vendor lock-in - the code is CC0, do whatever you want with it
|
||||
- No "free tier" that mysteriously degrades when you need it most
|
||||
- No dark patterns nudging people toward paid plans
|
||||
- Your users interact with YOUR instance, not a platform that treats them as product
|
||||
|
||||
The tools people use to communicate should belong to the people using them. Not to shareholders, not to VCs, not to ad networks. Echoboard is a small step in that direction.
|
||||
|
||||
## 📦 Features
|
||||
|
||||
### For everyone
|
||||
- Submit feature requests and bug reports without any account
|
||||
- Vote on posts with configurable budgets (so no one person dominates)
|
||||
- Comment with markdown, @mentions, emoji reactions, and file attachments
|
||||
- Dark and light themes that follow system preference
|
||||
- Full-text search across all boards
|
||||
- Similar post detection (so you find existing requests before duplicating)
|
||||
- RSS feeds for every board
|
||||
- Push notifications for status changes and new posts
|
||||
- Recovery codes for people who can't use passkeys
|
||||
- Keyboard accessible and screen reader friendly throughout
|
||||
|
||||
### For admins and teams
|
||||
- Dashboard with stats, post management, and bulk actions
|
||||
- Custom statuses per board (not just open/closed - whatever fits your workflow)
|
||||
- Roadmap and changelog pages with scheduled publishing
|
||||
- Team system - invite admins and moderators without them needing email accounts
|
||||
- Granular locking - lock post edits, individual comments, entire threads, or voting
|
||||
- Post merging for duplicates with vote consolidation
|
||||
- Edit history with rollback for both posts and comments
|
||||
- Embed widget you can drop into any external site
|
||||
- Webhooks for status changes, new posts, and comments
|
||||
- View counts on every post
|
||||
- Data export in CSV and JSON
|
||||
|
||||
### Infrastructure
|
||||
- Runs in Docker with a single `docker compose up -d`
|
||||
- PostgreSQL for storage, no external services needed
|
||||
- Plugin system - upload zip plugins through the dashboard, no restart required
|
||||
- ALTCHA proof-of-work spam protection (no CAPTCHAs bothering your users)
|
||||
- Field-level encryption for personally identifiable data
|
||||
- HSTS, CSP, and all the security headers you'd expect
|
||||
- Automatic database migrations on startup
|
||||
|
||||
## 🚀 Getting started
|
||||
|
||||
### 1. Clone and configure
|
||||
|
||||
```bash
|
||||
git clone https://git.lashman.live/lashman/echoboard.git
|
||||
@@ -10,54 +93,43 @@ cd echoboard
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and fill in:
|
||||
Open `.env` in your editor and fill in:
|
||||
|
||||
1. Set `POSTGRES_PASSWORD` to something random
|
||||
2. Generate five secrets (one for each of `APP_MASTER_KEY`, `APP_BLIND_INDEX_KEY`, `TOKEN_SECRET`, `JWT_SECRET`, `ALTCHA_HMAC_KEY`):
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
3. Generate VAPID keys for push notifications:
|
||||
```bash
|
||||
npx web-push generate-vapid-keys
|
||||
```
|
||||
4. Set `WEBAUTHN_RP_ID` to your domain (e.g. `feedback.example.com`)
|
||||
5. Set `WEBAUTHN_ORIGIN` to your full URL (e.g. `https://feedback.example.com`)
|
||||
**Database password** - set `POSTGRES_PASSWORD` to something random.
|
||||
|
||||
Then start it:
|
||||
**Encryption keys** - generate five secrets, one for each of `APP_MASTER_KEY`, `APP_BLIND_INDEX_KEY`, `TOKEN_SECRET`, `JWT_SECRET`, and `ALTCHA_HMAC_KEY`:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
Run that five times, paste each output into the corresponding variable.
|
||||
|
||||
**Push notification keys** - generate VAPID keys:
|
||||
|
||||
```bash
|
||||
npx web-push generate-vapid-keys
|
||||
```
|
||||
|
||||
Paste the public and private keys into `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY`.
|
||||
|
||||
**Domain settings** - set `WEBAUTHN_RP_ID` to your domain (e.g. `feedback.example.com`) and `WEBAUTHN_ORIGIN` to the full URL (e.g. `https://feedback.example.com`).
|
||||
|
||||
### 2. Start it
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Visit `http://localhost:3000/admin` (or whatever port you set) and create your admin account right in the browser. This setup screen only appears once - before any admin exists.
|
||||
That's it. Two containers come up - the app and a PostgreSQL database. Migrations run automatically.
|
||||
|
||||
Put a reverse proxy in front for HTTPS (required for passkeys to work).
|
||||
### 3. Create your admin account
|
||||
|
||||
## What's included
|
||||
Visit `/admin` in your browser. Since no admin exists yet, you'll see a setup screen where you pick an email and password. This screen disappears forever after the first admin is created.
|
||||
|
||||
- Boards for organizing feedback by project or topic
|
||||
- Feature requests and bug reports with voting and vote budgets
|
||||
- Comments with markdown, @mentions, reactions, and file attachments
|
||||
- Status tracking with custom statuses per board
|
||||
- Roadmap and changelog pages (with scheduled publishing)
|
||||
- Full-text search with similar post detection
|
||||
- Push notifications and board-level subscriptions
|
||||
- RSS feeds per board
|
||||
- Post view counts
|
||||
- Admin dashboard with post management, merge, bulk actions, edit rollback
|
||||
- Team system with invites - super admin, admin, and moderator roles
|
||||
- Granular locking - lock post edits, comment edits, threads, or voting independently
|
||||
- Recovery codes for cookie-based users who can't use passkeys
|
||||
- Plugin system - upload zip plugins through the admin dashboard, no restart needed
|
||||
- Embed widget for external sites
|
||||
- Dark and light themes
|
||||
- i18n ready
|
||||
- ALTCHA proof-of-work spam protection (no captchas)
|
||||
### 4. Set up HTTPS
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
Echoboard needs HTTPS for passkeys to work. Example nginx config:
|
||||
Passkeys require HTTPS to work. Put a reverse proxy in front of Echoboard. With nginx:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -78,42 +150,328 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
## Updating
|
||||
## 🔄 Updating
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Migrations run automatically on startup.
|
||||
Database migrations run automatically on startup. Your data, uploads, and plugins are preserved.
|
||||
|
||||
## Data
|
||||
## 💾 Data and backups
|
||||
|
||||
All persistent data lives next to the compose file:
|
||||
All persistent data lives in directories next to the compose file:
|
||||
|
||||
- `./data/postgres/` - database
|
||||
- `./uploads/` - avatars and attachments
|
||||
- `./plugins-installed/` - uploaded plugins
|
||||
| Directory | Contents |
|
||||
|---|---|
|
||||
| `./data/postgres/` | Database files |
|
||||
| `./uploads/` | User avatars and file attachments |
|
||||
| `./plugins-installed/` | Uploaded plugin files and assets |
|
||||
|
||||
## Plugins
|
||||
Back these up however you normally back things up. The database can also be dumped with `docker compose exec db pg_dump -U echoboard echoboard > backup.sql`.
|
||||
|
||||
Plugins are zip files uploaded through the admin dashboard. A plugin contains a `manifest.json` and a JS entry point. Plugins can add API routes, respond to events (post created, status changed, etc.), and store their own data. They run with full server access so only install plugins you trust.
|
||||
## 👥 Team system
|
||||
|
||||
A Gitea sync plugin is included in `plugins/gitea-sync/` as an example.
|
||||
Echoboard supports multiple team members without anyone needing an email account.
|
||||
|
||||
## Development
|
||||
**Super admin** - the person who set up the instance. Full access to everything. Can invite admins and moderators, manage branding, configure webhooks, install plugins. Cannot be removed.
|
||||
|
||||
**Admin** - can do most things the super admin can, except change branding or install plugins. Can invite moderators. Sees their own invitees in the team list.
|
||||
|
||||
**Moderator** - can respond to posts, change statuses, pin, merge, lock, and manage tags and categories. Cannot configure boards, webhooks, or invite others.
|
||||
|
||||
To add a team member: go to Team in the admin panel, click Invite, choose a role and expiry. Share the invite link. The person claims it, picks a display name, and secures their account with a passkey or recovery phrase. No email involved.
|
||||
|
||||
Each team member gets a display name and optional team title (like "Product Lead" or "Support") that shows alongside their responses in the public feed.
|
||||
|
||||
## 🔒 Identity and privacy
|
||||
|
||||
Echoboard treats identity as a spectrum, not a binary:
|
||||
|
||||
**Anonymous** (default) - a random token stored in a browser cookie. Zero friction, zero data collection. If cookies get cleared, the identity is gone.
|
||||
|
||||
**Recovery code** (optional) - a six-word phrase the user saves somewhere. Lets them get back to their anonymous identity if cookies are lost. Single-use, expires after 90 days.
|
||||
|
||||
**Passkey** (optional) - WebAuthn biometric or device PIN. The strongest option - works across devices, no password to remember or leak. The private key never leaves the user's device.
|
||||
|
||||
At every tier, the user's display name is encrypted at rest. Blind indexes allow lookups without exposing the plaintext. There is no email field, no phone number field, no "real name" field. The person decides how much of themselves to share.
|
||||
|
||||
## 🧩 Plugin system
|
||||
|
||||
Plugins extend Echoboard without modifying the core code. They're zip files uploaded through the admin dashboard - no server restart, no command line.
|
||||
|
||||
### What plugins can do
|
||||
|
||||
- **Add API routes** - mounted under `/api/v1/plugins/<pluginId>/your-path`
|
||||
- **React to events** - get called when posts are created, statuses change, comments are added
|
||||
- **Store data** - a key-value store backed by the database, scoped per plugin
|
||||
- **Read the database** - full read/write access to all models via Prisma
|
||||
- **Declare config fields** - the admin sees a proper form (text, password, number, boolean, select) instead of raw JSON
|
||||
|
||||
### Plugin structure
|
||||
|
||||
A plugin is a zip containing at minimum:
|
||||
|
||||
```
|
||||
manifest.json
|
||||
index.js
|
||||
```
|
||||
|
||||
Optional:
|
||||
```
|
||||
assets/ (static files - fonts, images, CSS)
|
||||
```
|
||||
|
||||
### manifest.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "What this plugin does",
|
||||
"author": "you",
|
||||
"entryPoint": "index.js",
|
||||
"configSchema": [
|
||||
{
|
||||
"key": "apiUrl",
|
||||
"type": "text",
|
||||
"label": "API endpoint",
|
||||
"placeholder": "https://api.example.com",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "apiKey",
|
||||
"type": "password",
|
||||
"label": "API key"
|
||||
},
|
||||
{
|
||||
"key": "syncInterval",
|
||||
"type": "number",
|
||||
"label": "Sync interval (minutes)"
|
||||
},
|
||||
{
|
||||
"key": "enabled",
|
||||
"type": "boolean",
|
||||
"label": "Enable automatic sync"
|
||||
},
|
||||
{
|
||||
"key": "region",
|
||||
"type": "select",
|
||||
"label": "Region",
|
||||
"options": [
|
||||
{ "value": "us", "label": "United States" },
|
||||
{ "value": "eu", "label": "Europe" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `name` must be lowercase alphanumeric with dashes. `configSchema` is optional - if omitted, the admin sees a raw JSON editor for configuration.
|
||||
|
||||
### index.js
|
||||
|
||||
The entry point must export a descriptor object (ESM `export default`):
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
// API routes (optional)
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/sync",
|
||||
async handler(req, reply, ctx) {
|
||||
// req and reply are Fastify objects
|
||||
// ctx is the plugin context (see below)
|
||||
reply.send({ ok: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/status",
|
||||
async handler(req, reply, ctx) {
|
||||
const lastRun = await ctx.store.get("lastRun")
|
||||
reply.send({ lastRun })
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
// Event handlers (optional)
|
||||
events: {
|
||||
async post_created(data, ctx) {
|
||||
// data: { postId, title, type, boardId, boardSlug }
|
||||
ctx.logger.info({ postId: data.postId }, "new post")
|
||||
},
|
||||
async status_changed(data, ctx) {
|
||||
// data: { postId, title, boardId, from, to }
|
||||
if (data.to === "PLANNED") {
|
||||
// do something when a post moves to planned
|
||||
}
|
||||
},
|
||||
async comment_added(data, ctx) {
|
||||
// data: { commentId, postId, body }
|
||||
}
|
||||
},
|
||||
|
||||
// Called when the plugin is enabled (optional)
|
||||
async onEnable(ctx) {
|
||||
ctx.logger.info("plugin enabled")
|
||||
// good place for initial sync, setup, etc.
|
||||
},
|
||||
|
||||
// Called when the plugin is disabled (optional)
|
||||
async onDisable(ctx) {
|
||||
ctx.logger.info("plugin disabled")
|
||||
// cleanup intervals, connections, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin context
|
||||
|
||||
Every handler receives a `ctx` object with:
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `ctx.prisma` | PrismaClient | Full database access - query any model |
|
||||
| `ctx.store` | PluginStore | Key-value storage scoped to this plugin |
|
||||
| `ctx.config` | object | The plugin's config (set by admin in dashboard) |
|
||||
| `ctx.logger` | Logger | Structured logger (info, warn, error) |
|
||||
| `ctx.pluginId` | string | The plugin's database ID |
|
||||
|
||||
### Plugin store
|
||||
|
||||
The store is a simple key-value interface backed by the database:
|
||||
|
||||
```javascript
|
||||
await ctx.store.set("lastSync", new Date().toISOString())
|
||||
const lastSync = await ctx.store.get("lastSync")
|
||||
await ctx.store.delete("lastSync")
|
||||
const all = await ctx.store.list() // [{ key, value }]
|
||||
```
|
||||
|
||||
Values are stored as JSON - you can store strings, numbers, objects, arrays.
|
||||
|
||||
### Available events
|
||||
|
||||
| Event | Fired when | Payload |
|
||||
|---|---|---|
|
||||
| `post_created` | A new post is submitted | `{ postId, title, type, boardId, boardSlug }` |
|
||||
| `status_changed` | An admin changes a post's status | `{ postId, title, boardId, from, to }` |
|
||||
| `comment_added` | A comment is posted | `{ commentId, postId, body }` |
|
||||
|
||||
### Static assets
|
||||
|
||||
Put files in an `assets/` directory in your zip. They're served at:
|
||||
|
||||
```
|
||||
/api/v1/admin/plugins/<pluginId>/assets/<filename>
|
||||
```
|
||||
|
||||
Allowed file types: CSS, JS, PNG, JPG, GIF, SVG, WebP, WOFF, WOFF2, TTF, EOT, JSON.
|
||||
|
||||
### Plugin ideas
|
||||
|
||||
- **Slack/Discord notifications** - post to a channel when new feedback comes in or statuses change
|
||||
- **Linear/Jira sync** - create tickets from high-voted posts, sync status back
|
||||
- **GitHub issues mirror** - two-way sync between boards and GitHub repos
|
||||
- **Email digest** - weekly summary of new feedback and status changes
|
||||
- **Auto-triage** - move posts to categories based on keywords
|
||||
- **Vote threshold actions** - auto-promote posts to "planned" when they hit N votes
|
||||
- **Gitea/Forgejo sync** - create boards from repos (included as example plugin)
|
||||
- **Analytics export** - push feedback data to your analytics pipeline
|
||||
- **Webhook relay** - forward events to services that don't support webhooks natively
|
||||
|
||||
### Security note
|
||||
|
||||
Plugins run with full server access. They can read and write any data in the database, make network requests, and execute arbitrary code. Only install plugins you trust, written by people you trust. This is the same trust model as WordPress plugins, Grafana plugins, or any other self-hosted extension system.
|
||||
|
||||
Only the super admin can install, enable, disable, or delete plugins.
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- PostgreSQL 14+ (or use Docker)
|
||||
- npm
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone https://git.lashman.live/lashman/echoboard.git
|
||||
cd echoboard
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# set WEBAUTHN_RP_ID=localhost, WEBAUTHN_ORIGIN=http://localhost:5173
|
||||
# point DATABASE_URL at a local postgres
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
- Set `WEBAUTHN_RP_ID=localhost`
|
||||
- Set `WEBAUTHN_ORIGIN=http://localhost:5173`
|
||||
- Point `DATABASE_URL` at a local PostgreSQL instance
|
||||
- Generate and fill in the encryption keys (same process as production)
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
API on port 3001, Vite dev server on port 5173.
|
||||
This starts:
|
||||
- API server on port 3001 (Fastify + tsx watch)
|
||||
- Frontend dev server on port 5173 (Vite with HMR)
|
||||
|
||||
## License
|
||||
The Vite dev server proxies API requests to port 3001 automatically.
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
echoboard/
|
||||
packages/
|
||||
api/ Backend (Fastify + Prisma)
|
||||
src/
|
||||
cli/ Admin creation CLI
|
||||
cron/ Scheduled jobs
|
||||
lib/ Shared utilities
|
||||
middleware/ Auth, security
|
||||
plugins/ Plugin loader, registry, context
|
||||
routes/ API endpoints
|
||||
admin/ Admin-only endpoints
|
||||
services/ Encryption, push, webhooks
|
||||
prisma/
|
||||
schema.prisma Database schema
|
||||
migrations/ SQL migrations
|
||||
web/ Frontend (React + Vite)
|
||||
src/
|
||||
components/ Reusable UI components
|
||||
hooks/ React hooks (auth, admin, toast, etc.)
|
||||
i18n/ Translations
|
||||
lib/ API client, utilities
|
||||
pages/ Route pages
|
||||
admin/ Admin panel pages
|
||||
public/ Static assets (service worker, embed script)
|
||||
plugins/ Example plugins
|
||||
docker-compose.yml Production deployment
|
||||
Dockerfile Multi-stage build
|
||||
```
|
||||
|
||||
### Key architectural decisions
|
||||
|
||||
**Anonymous-first identity** - users get a random token cookie on first visit. No signup wall, no email collection. Passkeys are an optional upgrade, not a requirement.
|
||||
|
||||
**Field-level encryption** - display names and other PII are encrypted with AES-256-GCM before storage. Blind indexes (HMAC-SHA256) enable lookups without exposing plaintext.
|
||||
|
||||
**ALTCHA proof-of-work** - instead of CAPTCHAs (which are inaccessible, annoying, and feed surveillance companies), spam is deterred by making the client's browser solve a small computational puzzle. Invisible to users, hostile to bots.
|
||||
|
||||
**Plugin descriptor pattern** - plugins export a descriptor object instead of directly registering Fastify routes. This lets the system load and unload plugins at runtime without restarting the server.
|
||||
|
||||
**Granular locking** - instead of a single "lock" toggle, posts have three independent locks (post edits, thread/comments, voting). Comments can also be individually locked. This gives moderators precise control without over-restricting discussion.
|
||||
|
||||
## 📄 License
|
||||
|
||||
CC0-1.0
|
||||
|
||||
This work is dedicated to the public domain. You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission. No attribution required.
|
||||
|
||||
Because tools for collective communication shouldn't be enclosed behind intellectual property fences. Take it, use it, make it yours.
|
||||
|
||||
Reference in New Issue
Block a user