A real-time chat platform with a genuinely separated client and server, and two distinct kinds of conversation — the same split Telegram uses:
- Cloud Chats (default) — stored server-side, synced instantly across every device the moment you log in. No keys, no setup, no way to lose history by losing a browser.
- Secret Chats (opt-in) — genuinely end-to-end encrypted using the browser's native Web Crypto API. The server only ever stores ciphertext. The trade-off, same as real Telegram: these live on the device(s) where you've used them, not your account in general.
server/ Express + Socket.IO + MongoDB API, MVC-style. No view layer.
Cloud Chats: stores/reads plain messages, like any normal chat app.
Secret Chats: stores ciphertext only — it cannot read these.
client/ Framework-free static JS app (its own model/view/controller split).
Cloud Chats: sends/receives plain text, no crypto involved.
Secret Chats: encrypts/decrypts entirely client-side before
anything touches the network.
- Features
- Stack
- Getting started
- Project structure
- Cloud Chats vs. Secret Chats
- How the client/server separation works
- How Cloud Chat encryption-at-rest works
- How Secret Chat encryption works
- Security model
- How the real-time architecture works
- Phasetime SSO (optional)
- Notes & possible extensions
- Groups, blocking & notifications
- Messages, read state & unread counts
- Security hardening
- Deployment & operations
- Testing
- Contributing
- License
- Author
- Accounts — register/login with hashed passwords (bcrypt), sessions stored in MongoDB, or sign in with Phasetime SSO if configured
- Cloud Chats — private chats and groups that just work: log in anywhere, your conversations and files are already there
- Secret Chats — opt-in, genuinely end-to-end encrypted 1:1 chats; the server never sees plaintext
- Online status — presence dots update live as people connect/disconnect, even across multiple tabs
- Typing indicators — see when someone in the open chat is typing, in real time
- Message history — cursor-paginated; scroll up in a thread to load older messages
- File/image attachments — Telegram-style: filename and size show immediately, images preview inline, one click downloads straight to your browser's normal download flow
Server: Express · Socket.IO · MongoDB (Mongoose) · express-session (Mongo-backed) Client: Vanilla JavaScript (ES modules, no bundler) · Web Crypto API (Secret Chats only) · Tailwind CSS (CDN) · Font Awesome
Prerequisites: Node.js 18+, and a MongoDB instance (local install or a free MongoDB Atlas cluster). Secret Chats need a reasonably modern evergreen browser (Chrome, Firefox, Safari, or Edge) for the Web Crypto API and IndexedDB — Cloud Chats work anywhere.
# 1. Clone the repo
git clone https://github.com/RealUnfazed/unisontalk.git
cd unisontalk
# 2. Install dependencies
npm install
# 3. Configure environment
cp .env.example .env
# then edit .env: set MONGODB_URI, a random SESSION_SECRET, and
# CLOUD_ENCRYPTION_KEY (a 64-char hex string — .env.example shows how to
# generate one). The server won't start without a valid encryption key.
# 4. Run it
npm run dev # with nodemon, auto-restarts on changes
# or
npm startThen open http://localhost:3000, register two accounts (e.g. in two different browsers or an incognito window), and start chatting between them. Try a normal chat first, then use the "New secret chat" option in the + menu to see the encrypted flow.
server/
server.js Entry point: connects MongoDB, starts HTTP + Socket.IO
app.js Express app config — CORS, sessions, routes, static serving
config/db.js MongoDB connection
config/phasetime.js Phasetime SSO config (returns null if unconfigured — feature is optional)
models/ Mongoose schemas: User, Chat (isGroup/isSecret), Message
controllers/ Request handlers — auth, chats/messages, uploads, Phasetime SSO
routes/ Route definitions, wired to controllers
middleware/ Session-based auth guard, Multer upload config
sockets/index.js All Socket.IO logic — branches on chat.isSecret
utils/serialize.js Shapes Mongoose docs into client-friendly JSON
utils/fieldCrypto.js Cloud Chat encryption-at-rest (AES-256-GCM, server-held key)
utils/chatPopulate.js Shared Chat query population spec
utils/chatVisibility.js Shared "should this chat show in my list yet" rule
uploads/ Attachment files (plain for Cloud, ciphertext for Secret)
client/
public/ Static HTML shells: index.html, login.html, register.html, 404.html
src/
crypto/ webcrypto.js (ECDH+AES-GCM primitives), keyStore.js (IndexedDB
persistence), identity.js, chatKeys.js — the Secret Chat
implementation. Cloud Chats never import any of this.
models/AppState.js Client-side state + a small pub/sub mechanism
views/ Pure rendering: SidebarView, ChatView, ModalView
controllers/ ChatController — wires sockets, the API, crypto (when
needed), and views together; the isSecret branch point
pages/ Per-page bootstrap scripts (chatPage, loginPage, registerPage,
ssoStatus.js shared by the auth pages)
api.js, socket.js, config.js Thin wrappers around fetch / Socket.IO / base URLs
Every 1:1 chat is one or the other; groups are always Cloud Chats (see Security model for why).
| Cloud Chat (default) | Secret Chat (opt-in) | |
|---|---|---|
| Where messages live | MongoDB, encrypted at rest with a server-held key (the app can still read/process them — see Security model) | MongoDB, as ciphertext the server has no key for at all |
| Available on a new device? | Yes — log in, it's there | No — tied to the device(s) it's been used on |
| Setup required | None | None to use — a key is generated automatically the first time you open one |
| Groups? | Yes | No (1:1 only — see below) |
| Good for | Everyday conversations | Anything where you specifically don't want it recoverable from the server |
Starting a Secret Chat requires the other person to have opened one before (so their public key is on file) — if they haven't, you'll be told to ask them to start one with you first.
The server is a pure JSON + WebSocket API — there's no res.render() anywhere in it, and no template engine. The client is a fully static app: plain HTML files plus ES modules, no build step (<script type="module"> handles the model/view/controller split natively in the browser).
By default, server/app.js also serves client/ as static files, so npm run dev gives you one command and one running process. But nothing in the client actually depends on that:
client/src/config.jsexportsAPI_BASE_URL/SOCKET_URL. Point these at a different host and the client works talking to a server running anywhere else.- The server has CORS enabled (
server/app.js) and reflects the request's origin withcredentials: trueby default, so a client served from a different port or domain during development just works. SetALLOWED_ORIGINin.envto lock this down before deploying.
Cloud Chats aren't end-to-end encrypted — the server genuinely needs to read them to do its job (search, moderation, cross-device sync without any client-side key). But "the server can read it" doesn't have to mean "anyone who steals a copy of the database can read it too."
server/utils/fieldCrypto.js encrypts message text and attachment filenames with AES-256-GCM before they're written to MongoDB, and decrypts them again — once, in utils/serialize.js — on the way back out to the API. The key (CLOUD_ENCRYPTION_KEY) lives only in the server's environment, never in the database itself. That split is the whole point: a database dump, a stolen backup, or leaked DB credentials are a different failure than a leaked environment variable, so getting one doesn't automatically hand over the other.
What this protects against: someone getting a copy of your MongoDB data (a backup, a dump, an unsecured database exposed to the internet) without also getting your server's environment.
What it doesn't protect against: a compromised, running server process — which has the key loaded in memory to do its job — or anyone with direct access to that environment. That's an inherent limit of any system where the server needs to process the content, not a bug specific to this implementation; it's the same trade-off full-disk encryption or a cloud provider's KMS makes. If a conversation needs to be unreadable even to a fully compromised server, that's what Secret Chats are for.
One practical consequence worth knowing: losing CLOUD_ENCRYPTION_KEY, or changing it, makes existing Cloud Chat messages permanently unreadable (there's graceful handling so the app doesn't crash — it shows [This message could not be decrypted] instead — but the data is genuinely gone). Back this key up somewhere durable once there's real data behind it.
Only Secret Chats touch any of this — Cloud Chats never call client/src/crypto/* at all.
The first time a person opens or starts a Secret Chat, their browser generates a long-term ECDH key pair (P-256 curve) via crypto.subtle.generateKey. Only the public half is ever sent to the server (POST /api/auth/keys) — stored in User.publicKey as a simple directory, the same way an email server stores addresses. The private key is generated, used, and persisted (in IndexedDB) entirely client-side and never crosses the network in any form.
This is deliberately lazy: an account that never opens a Secret Chat never generates a key, never uploads a public key, and pays none of this cost. Registration and login stay exactly as fast either way.
From there, encrypting a message is the core trick of Diffie-Hellman key exchange: Alice combines her private key with Bob's public key, and Bob combines his private key with Alice's public key — they arrive at the exact same shared secret without ever transmitting it. Both sides derive an AES-256-GCM key this way independently, whenever they open the chat. Attachments get the same treatment: the file's bytes and a small JSON blob holding its real filename/type are both encrypted client-side before upload, so the server genuinely can't tell what a file is, just that ciphertext of some size exists.
All of this is built on the browser's native crypto.subtle — no third-party crypto dependency, nothing that could quietly no-op. The full implementation is in client/src/crypto/*.js, deliberately kept small enough to actually read.
Key persistence, and why it's stored the way it is: the identity key pair is cached in IndexedDB as exported, portable bytes (PKCS8 for the private key, SPKI for the public key — see crypto/keyStore.js), not as a raw CryptoKey object. Browsers can store a CryptoKey directly via structured-clone support, but that's a newer capability with an unforgiving failure mode here: if it silently doesn't round-trip, the app would quietly generate a new identity on every visit, permanently breaking decryption of everything encrypted under the old one — with no error, since ECDH derivation doesn't "fail" for a wrong key, it just produces a different one. Exporting to plain, portable bytes sidesteps that risk entirely.
| Cloud Chats | Secret Chats |
|---|---|
Encrypted at rest with a server-held key (CLOUD_ENCRYPTION_KEY, see server/utils/fieldCrypto.js) — the server still processes them normally (that's what makes "log in anywhere, see everything" possible with zero client-side key management), but a raw database dump, stolen backup, or leaked DB credentials yield ciphertext, provided the encryption key wasn't also exposed — it deliberately lives only in the environment, never in the database. This does not protect against a fully compromised, running server process, which has the key loaded to do its job — same trade-off as disk encryption or any server-side KMS. |
Server and database only ever hold ciphertext, and the server never has the key at all, not even in memory. A full database compromise or legal request for stored data does not expose Secret Chat contents, full stop — there's no key on the server side to also compromise. |
For Secret Chats specifically, being upfront about what the encryption does and doesn't protect against matters more than the phrase "end-to-end encrypted" on its own:
| Secret Chats have… | Secret Chats do NOT have (yet)… |
|---|---|
| Real client-side AES-256-GCM + ECDH encryption | Forward secrecy — a compromised long-term key exposes every past message encrypted under it, since there's no ratcheting |
| A server that only ever stores/routes ciphertext | Safety-number / key verification UI — no way to manually confirm someone's public key is really theirs, so a fully compromised, actively malicious server could in principle substitute its own key and sit in the middle of a conversation. (A passive server that just logs traffic cannot read anything, regardless.) |
| Encrypted attachments (bytes and metadata) | Group support — there's no single "other party" to Diffie-Hellman with in a group, so real E2EE groups need per-member key distribution (Signal's "sender keys," or similar); out of scope here, groups are Cloud Chats only |
| Device-local keys, generated lazily, no setup friction | Multi-device sync for Secret Chats specifically — a new browser gets a new key, and Secret Chats from before that point won't decrypt there. This mirrors Telegram's own Secret Chats exactly, and does not affect Cloud Chats at all. |
None of this is hidden in the implementation — see the comments at the top of client/src/crypto/webcrypto.js for the same list in context.
One login, two transports. Express sessions are normally only readable by HTTP requests. server/server.js creates a single express-session middleware instance and uses it twice: once as normal Express middleware, and once wrapped for Socket.IO in server/sockets/index.js:
const wrap = (middleware) => (socket, next) => middleware(socket.request, {}, next);
io.use(wrap(sessionMiddleware));This lets a socket connection read socket.request.session.userId — the same cookie the browser already sent — so there's no separate token or handshake step to log in twice.
Rooms model "who should hear this." Each chat's MongoDB _id doubles as a Socket.IO room name. When a user connects, their socket joins a room for every chat they belong to, plus a personal user:<id> room used for account-wide notifications (like "you were just added to a new group"). Sending a message becomes: save it to MongoDB (plain for Cloud, ciphertext for Secret), then io.to(chatId).emit('new-message', ...) — every connected participant gets it instantly, including the sender's other open tabs.
Presence uses reference counting, not just connect/disconnect. A user can have the app open in multiple tabs. server/sockets/index.js keeps an in-memory Map of userId -> Set of socket ids so a user is only marked offline once their last tab disconnects — and it does this from the disconnecting event (not disconnect), because that's the last moment a socket still knows which rooms it belonged to.
Typing indicators are deliberately not persisted. They're pure ephemeral events (socket.to(chatId).emit('typing', ...)) with a client-side timeout as a safety net in case a "stop typing" event is ever lost — nothing touches the database for this.
UnisonTalk can optionally support signing in via Phasetime SSO, an open-source, self-hostable single sign-on server — either the public instance at account.unfazed.ir or your own deployment of the same software. It's a standard OAuth2 Authorization Code flow, so this is a normal OAuth client integration, not anything Phasetime-specific under the hood.
It's entirely optional. Leave PHASETIME_SSO_BASE_URL, PHASETIME_CLIENT_ID, PHASETIME_CLIENT_SECRET, and PHASETIME_REDIRECT_URI unset in .env and the feature simply doesn't appear — no "Continue with Phasetime" button, no forced setup, nothing else in the app depends on it. server/config/phasetime.js returning null when any of those four are missing is what every SSO route checks before doing anything.
- Register this app as a client in your Phasetime instance's admin panel to get a client ID and secret.
- Set the four
PHASETIME_*variables in.env— see.env.examplefor the exact names and a walkthrough. PHASETIME_REDIRECT_URImust match what's registered in Phasetime's admin panel exactly, byte for byte — this is a security requirement of the SSO server itself (prevents a stolen authorization code from being redeemed against a different URL), not a UnisonTalk quirk.
GET /api/auth/sso/phasetime— starts a login/signup attempt: generates a randomstatevalue (stored in the session, for CSRF protection), then redirects the browser to Phasetime's/authorize.php.GET /api/auth/sso/phasetime/callback— Phasetime redirects back here with a one-timecode. The server exchanges it for an access token via a server-to-serverPOST /token.phpcall (the only placePHASETIME_CLIENT_SECRETis ever used — it never reaches the browser), then fetches{ id, name, email }from/userinfo.php.- If a local account is already linked to that Phasetime
id, that account gets signed in. Otherwise, a new account is created automatically, with a username derived from the Phasetime profile's display name (sanitized to fit UnisonTalk's username rules, with a numeric suffix appended if there's a collision) and no local password at all — signing in with a plain username/password is simply never possible for an account that was never given one, without needing a special "SSO-only" flag anywhere. - Existing accounts can link a Phasetime identity instead of creating a duplicate:
GET /api/auth/sso/phasetime/link(only reachable while already logged in) runs the same OAuth round trip, but attaches the resulting Phasetime ID to the current account instead of creating or looking up a new one. There's a "Link Phasetime account" button for this in the sidebar (next to logout) whenever SSO is configured; it switches to "Unlink" once linked. - If a fresh sign-in's Phasetime email matches an existing, unlinked local account, UnisonTalk deliberately does not auto-merge them — that's exactly the kind of assumption that fails quietly and unpredictably (email verification guarantees vary, and typo-squatted or previously-abandoned addresses exist). It shows a clear message instead, telling the person to log in normally and link explicitly.
None of this touches Cloud vs. Secret Chats — a Phasetime-authenticated account works exactly like a normal one everywhere else in the app once signed in.
- Profile: an uploaded profile picture (falls back to a generated one), and a display name separate from the fixed, unique
username(search and starting chats still key off the username; everywhere a name is shown uses the display name). - Groups: name, description, and picture — editable by the group's creator only; everyone can view them, and changes appear for other members in real time.
- Privacy: show my last seen & online status (off = others see "last seen recently", no online dot, and no presence events are even broadcast for you) and let people find me by username (off = excluded from search; existing chats and exact-username lookups are unaffected).
- Media: an auto-preview size limit (0 turns it off) applied to image attachments in both Cloud and Secret Chats — anything larger waits for an explicit click.
Deliberately simpler than Telegram: no "My Contacts" tier (there's no contacts list to key one off), last-seen privacy isn't reciprocal (hiding yours doesn't hide others' from you), and there's no separate profile-picture or "who can add me to groups" toggle yet. All good candidates for follow-up work.
- Cloud Chat attachments and Secret Chat ciphertext both live on local disk (
server/uploads/) via Multer — fine for learning/dev; swap in S3/Cloud Storage for production. - There's no read-receipt/unread-count system yet.
- There's no way to add a member to an existing group or change a group's membership after creation.
- See Security model above and SECURITY.md for what to review before deploying this publicly.
- Managing a group: open the group (click its header). The creator can add people by username, remove members, and transfer ownership; anyone can leave. If the creator leaves, the longest-standing remaining member becomes the owner; when the last member leaves, the group and its messages are deleted. New members can read the history but start with nothing unread. Removed members stop receiving messages immediately and the chat disappears for them.
- Who can be added: people you've blocked, people who blocked you, and people who turned off Let people add me to groups are skipped, and you're told who was skipped. "They blocked you" and "they accept no invites" deliberately look identical, so adding people can't be used to find out who has blocked you.
- Blocking: from a private chat's header (contact info), a group's member list, or Settings → Blocked people. A block stops the other person from starting a chat with you or messaging you (the blocker can't send either until they unblock), hides you from each other's search, and hides each other's online status in private chats. Existing chats and history stay. Known limits: starting a chat with someone who blocked you says "you can't message this person" (it can't stay silent without faking delivery); in a shared group the blocked person can still see your presence and typing; blocking doesn't remove anyone from groups you share.
- Desktop notifications (Settings → Notifications, per browser): shown for messages in chats you're not looking at. Secret Chat text is never put in a notification — they only say "New message" — and you can turn previews off for Cloud Chats too. There's no per-chat mute yet.
- Not yet: service messages in groups ("X was added"), admin roles other than the creator, join links, per-chat mute.
- Reply, edit, delete: hover a message and use the chevron menu. Replies show a quote of the original (for Secret Chats the quote is decrypted in your browser). You can edit your own text messages for 48 hours — an "edited" label stays on them. Delete removes a message for everyone: the text/ciphertext and any attachment file are erased server-side and a placeholder remains. In groups, the creator can also delete other people's messages. Quotes of an edited or deleted message elsewhere refresh on reload.
- Unread badges: each chat shows how many messages from others are newer than your read marker (capped at 99+), and the tab title shows the total. Opening a chat, or returning to its tab, marks it read.
- Read receipts: in private chats your messages show one tick when sent and two once the other person has read them. Settings → Send read receipts turns your side off: others stop seeing when you read (and you still get correct unread counts). It isn't reciprocal. Groups don't show per-message receipts.
- Upgrading note: chats you had before this feature have no read marker yet, so their existing history counts as unread once, until you open them.
Everything below ships in the repo and is covered by tests; see SECURITY.md for the threat model and known limits.
- Rate limiting (in-memory per process, or shared across instances through Redis — see Deployment): login is limited per account+IP and per IP, registration per IP, uploads/search per user, plus a global API cap. Responses are
429withRetry-After. - Sessions: a fresh session ID is issued on every login/registration/SSO sign-in (no session fixation);
Securecookies in production; passwords are compared in constant-ish time whether or not the account exists, so response time doesn't reveal usernames. - Headers:
nosniff,X-Frame-Options,Referrer-Policy,Permissions-Policy, COOP, HSTS (production + HTTPS), and a Content-Security-Policy that starts in report-only mode — load the app, check the browser console, then setCSP_MODE=enforce. - Uploads: chat attachments are no longer public static files — they're served only to logged-in members of a chat that contains them, as inline images for safe raster types and sandboxed downloads for everything else (so an uploaded
.html/.svgcan never run as a page on your origin). Avatar file extensions come from the validated image type, never the filename. Attachment metadata received over the socket is rebuilt from validated fields (no arbitrary URLs → no tracking-pixel/IP-leak links). Rejected, replaced and abandoned uploads are cleaned up. - Input handling: every controller is wrapped so an unexpected error becomes a logged 500 rather than a crashed process; request bodies and socket payloads are type-checked; search input is escaped (no regex injection/ReDoS); socket
typingevents are only relayed into rooms you've actually joined. - Configuration: the server refuses to start in production with a weak
SESSION_SECRET; CORS allows nothing cross-origin in production unlessALLOWED_ORIGINis set. See.env.exampleforNODE_ENV,TRUST_PROXY,COOKIE_SECUREandCSP_MODE. - Supply chain: Dependabot (npm + GitHub Actions) and a non-blocking
npm auditjob in CI.
cp .env.example .env # set SESSION_SECRET and CLOUD_ENCRYPTION_KEY (see Security hardening)
docker compose up --build # app on http://localhost:3000, with MongoDBData lives in two named volumes (mongo-data, uploads). The compose file sets COOKIE_SECURE=false so login works over plain-HTTP localhost — remove it once you're behind TLS (and set TRUST_PROXY). The image runs as an unprivileged user, has a health check, and handles docker stop gracefully.
docker compose -f docker-compose.yml -f docker-compose.scale.yml up --build --scale app=2 # http://localhost:8080Adds Redis and an nginx load balancer (deploy/nginx.conf). Setting REDIS_URL makes instances cooperate: Socket.IO rooms and broadcasts span all instances (a message sent through one reaches sockets on another) and rate-limit counters are shared (if Redis goes down they fail open to per-instance counters rather than locking everyone out). Without REDIS_URL nothing changes — it's a single process and the redis package is never loaded. Things to know:
- Sticky sessions or WebSocket-only. Socket.IO starts with several HTTP long-polling requests that must hit the same instance. Either enable stickiness on your load balancer (the sample uses
ip_hash) or go WebSocket-only:SOCKET_TRANSPORTS=websocketon the server andSOCKET_TRANSPORTS = ['websocket']inclient/src/config.js. TRUST_PROXYmust be set behind a proxy, or every client looks like the proxy's IP (breaking per-IP rate limits).- Uploads are still on local disk. The sample shares one volume between instances, which only works on a single host. Running across hosts needs object storage (S3) — the next phase.
- nginx resolves the app replicas when it starts; restart it after scaling.
GET /healthz— liveness (always cheap).GET /readyz— readiness: checks MongoDB (and Redis when configured) and goes503during shutdown so the load balancer drains the instance. Neither creates sessions or is rate-limited, and failures name the dependency but never the reason.- Logs are structured:
LOG_FORMAT=json(the default in production) writes one JSON object per line for your log aggregator;prettyis for terminals.LOG_LEVELisdebug|info|warn|error|silent. Every request gets an id (an upstreamX-Request-Idis honoured if well-formed), echoed in theX-Request-Idheader and included in the body of any 500 so a user's report can be matched to a log line. Keys that look like secrets (password,token,cookie,authorization, ...) are redacted at any depth, and query strings are never logged (the SSO callback carries a code there). - Graceful shutdown (SIGTERM/SIGINT): fail readiness, optionally wait
SHUTDOWN_DRAIN_MSfor the balancer, drop this instance's sockets, settle presence, then close HTTP, Redis, sessions and MongoDB — with aSHUTDOWN_TIMEOUT_MShard stop. - Presence is a MongoDB flag flipped atomically (so exactly one instance announces each change) plus a cluster-wide socket count, with a
PRESENCE_GRACE_MSdelay before "offline": refreshing the page or a flaky connection no longer flickers someone offline. If a process is killed without a graceful shutdown, its users can stay "online" until they reconnect and disconnect again.
npm install
npm test # unit + integration
npm run test:unit # fast, no database
npm run test:integration # boots the real app against an in-memory MongoDB
npm run check # syntax-check every file (also runs first in CI)Tests use Node's built-in runner (no Jest). Unit tests cover the crypto (Secret Chat encrypt/decrypt, key export/import, tamper detection; Cloud encryption at rest), the serializers (privacy rules, per-viewer chat naming, decrypt-on-read) and username sanitisation. Integration tests drive the real Express app with supertest against mongodb-memory-server (it downloads a mongod binary on first run), covering registration/login, the Phasetime flow against a mock provider (including CSRF/state handling, linking, unlinking), profile/privacy/search, chat visibility and group editing. They include regressions for real bugs found so far — e.g. issue #14 (duplicate phasetimeId: null) and the wrong-name-for-the-recipient chat bug. GitHub Actions runs everything on Node 20 and 22.
Not covered yet: the Socket.IO layer (encryption-at-rest on the actual message write path, first-message chat notification, presence broadcasts) and the browser UI.
Contributions are welcome — bug reports, feature ideas, docs fixes, and pull requests all count.
- Read CONTRIBUTING.md for local setup, coding conventions, and the PR workflow.
- Please follow the Code of Conduct in all project spaces.
- Found a security issue? Please don't file a public issue — see SECURITY.md instead.
- Bug reports and feature requests use the templates that come up automatically when you open an issue.
Distributed under the MIT License. Copyright © Alireza Asakareh (RealUnfazed).
Alireza Asakareh (RealUnfazed)