<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://www.completenoobs.com/noobs/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=AwesomO</id>
	<title>CompleteNoobs - User contributions [en]</title>
	<link rel="self" type="application/atom+xml" href="https://www.completenoobs.com/noobs/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=AwesomO"/>
	<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/Special:Contributions/AwesomO"/>
	<updated>2026-05-30T16:09:16Z</updated>
	<subtitle>User contributions</subtitle>
	<generator>MediaWiki 1.43.1</generator>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=762</id>
		<title>Hive Blockchain Create Custom Coins</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=762"/>
		<updated>2026-05-26T17:32:20Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Exchanging tokens on an exchange */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_CC0}}&lt;br /&gt;
= v4call — How to Create Your Own Custom Token (e.g. CNOOBS) on Hive Engine =&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
F&lt;br /&gt;
This guide walks you through creating a custom Hive Engine token (example: &#039;&#039;&#039;CNOOBS&#039;&#039;&#039;) in under 10 minutes. No coding required.&lt;br /&gt;
&lt;br /&gt;
These tokens power the custom communication economy in v4call: you can set rates like &amp;quot;1 CNOOBS = 1 text message&amp;quot; or &amp;quot;10 CNOOBS = 1 hour video call&amp;quot;, gift them to friends/family, or let blocked users bypass your blocklist if they hold enough of your token.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why create your own token?&#039;&#039;&#039;&lt;br /&gt;
* Full control over supply and distribution.&lt;br /&gt;
* Real utility inside v4call (and any other Hive dApp that supports Hive Engine tokens).&lt;br /&gt;
* Scarcity you decide — perfect for personal or community communication economies.&lt;br /&gt;
* Transferable, tradable, and giftable via Hive Keychain or TribalDex.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Cost&#039;&#039;&#039;: Approximately 100 BEE (Hive Engine&#039;s utility token). BEE price fluctuates — check current value on TribalDex or PeakD. This is a one-time creation fee.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Source&#039;&#039;&#039;: Based on the official Hive Engine / TribalDex interface (as of 2026).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;End result&#039;&#039;&#039;: A live custom token (e.g. CNOOBS) that appears in users&#039; Hive Keychain wallets and can be used in your v4call rates post.&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
* [[#What_You_Need|1 What You Need]]&lt;br /&gt;
* [[#Step_1:_Get_Some_BEE_Tokens|2 Step 1: Get Some BEE Tokens]]&lt;br /&gt;
* [[#Step_2:_Log_In_to_TribalDex|3 Step 2: Log In to TribalDex]]&lt;br /&gt;
* [[#Step_3:_Create_Your_Token|4 Step 3: Create Your Token]]&lt;br /&gt;
* [[#Step_4:_Issue_Tokens_to_Yourself|5 Step 4: Issue Tokens to Yourself]]&lt;br /&gt;
* [[#Step_5:_Optional_-_Add_Metadata_(Logo_Description)|6 Step 5: Optional - Add Metadata (Logo &amp;amp; Description)]]&lt;br /&gt;
* [[#Step_6:_Distribute_Your_Tokens|7 Step 6: Distribute Your Tokens]]&lt;br /&gt;
* [[#Using_Your_Token_in_v4call|8 Using Your Token in v4call]]&lt;br /&gt;
* [[#Common_Problems_and_Fixes|9 Common Problems and Fixes]]&lt;br /&gt;
* [[#Quick_Reference|10 Quick Reference]]&lt;br /&gt;
&lt;br /&gt;
== What You Need ==&lt;br /&gt;
* A Hive account (e.g. @noblemage) with Hive Keychain installed and the &#039;&#039;&#039;active key&#039;&#039;&#039; available.&lt;br /&gt;
* Some BEE tokens (≈100 BEE for creation fee).&lt;br /&gt;
* A web browser.&lt;br /&gt;
&lt;br /&gt;
You do &#039;&#039;&#039;not&#039;&#039;&#039; need to run a node or write code.&lt;br /&gt;
&lt;br /&gt;
== Step 1: Get Some BEE Tokens ==&lt;br /&gt;
BEE is the &amp;quot;gas&amp;quot; token for Hive Engine actions.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com tribaldex.com] or any Hive Engine market (e.g. via PeakD wallet).&lt;br /&gt;
# Swap or buy BEE using HIVE or HBD (very easy with Keychain).&lt;br /&gt;
# Alternative: Many users get small amounts of BEE from community airdrops or by swapping on the built-in market.&lt;br /&gt;
&lt;br /&gt;
Make sure you have at least 110 BEE to cover the fee plus a small buffer.&lt;br /&gt;
&lt;br /&gt;
== Step 2: Log In to TribalDex ==&lt;br /&gt;
# Visit [https://tribaldex.com/tokens/create https://tribaldex.com/tokens/create]&lt;br /&gt;
# Click the login button at the top.&lt;br /&gt;
# Hive Keychain will pop up — approve the login with your &#039;&#039;&#039;active key&#039;&#039;&#039; (or posting key in some cases, but active is safest for token actions).&lt;br /&gt;
&lt;br /&gt;
You are now logged in as your Hive account.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Create Your Token ==&lt;br /&gt;
On the token creation form, fill in the following fields carefully. Most cannot be changed later.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Symbol&#039;&#039;&#039; — e.g. &#039;&#039;&#039;CNOOBS&#039;&#039;&#039; (uppercase, 1–10 characters, unique)&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; — e.g. &#039;&#039;&#039;Cnoobs Coins&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Max Supply&#039;&#039;&#039; — Choose a number (e.g. 1,000,000). This is the absolute maximum that can ever exist. You can issue less.&lt;br /&gt;
* &#039;&#039;&#039;Precision&#039;&#039;&#039; — Usually &#039;&#039;&#039;3&#039;&#039;&#039; (allows 0.001 precision). You can only increase this later, not decrease.&lt;br /&gt;
* &#039;&#039;&#039;Website&#039;&#039;&#039; (optional) — Link to your profile or v4call server (e.g. https://call.yourdomain.com)&lt;br /&gt;
* &#039;&#039;&#039;Description&#039;&#039;&#039; (optional) — Short text like &amp;quot;Personal communication token for v4call — used for messages and calls with @cnoobz&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Double-check everything — the symbol especially cannot be changed.&lt;br /&gt;
&lt;br /&gt;
Click &#039;&#039;&#039;Create Token&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Hive Keychain will ask you to approve a custom_json transaction. Confirm it.&lt;br /&gt;
&lt;br /&gt;
Wait 3–10 seconds for confirmation on the blockchain.&lt;br /&gt;
&lt;br /&gt;
Success message should appear: your token is now created!&lt;br /&gt;
&lt;br /&gt;
== Step 4: Issue Tokens to Yourself ==&lt;br /&gt;
After creation, you usually start with zero balance.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com/tokens/manage https://tribaldex.com/tokens/manage]&lt;br /&gt;
# Find your new token (CNOOBS) in the list.&lt;br /&gt;
# Click the &#039;&#039;&#039;Issue&#039;&#039;&#039; button.&lt;br /&gt;
# Enter the amount you want to issue to yourself (e.g. 10000).&lt;br /&gt;
# Confirm with Keychain.&lt;br /&gt;
&lt;br /&gt;
You now hold the full issued supply.&lt;br /&gt;
&lt;br /&gt;
== Step 5: Optional — Add Metadata (Logo &amp;amp; Description) ==&lt;br /&gt;
# Still on the manage page, click the edit icon for your token.&lt;br /&gt;
# Upload a square logo image (recommended 200x200 px or larger).&lt;br /&gt;
# Improve the description and website link.&lt;br /&gt;
# Save changes (another small Keychain transaction).&lt;br /&gt;
&lt;br /&gt;
This makes your token look professional when users view it in wallets or markets.&lt;br /&gt;
&lt;br /&gt;
== Step 6: Distribute Your Tokens ==&lt;br /&gt;
* Send to friends/family via Keychain → &amp;quot;Tokens&amp;quot; tab → Transfer.&lt;br /&gt;
* Airdrop to your community.&lt;br /&gt;
* Use in v4call rates (see below).&lt;br /&gt;
* List on TribalDex market if you want people to buy/sell them.&lt;br /&gt;
&lt;br /&gt;
Tokens are fully transferable and appear instantly in recipients&#039; Hive Keychain wallets.&lt;br /&gt;
&lt;br /&gt;
== Using Your Token in v4call ==&lt;br /&gt;
Once you have your token:&lt;br /&gt;
&lt;br /&gt;
# Go to your v4call server → /rate-editor.html&lt;br /&gt;
# In the rate editor (V2 format), create a new list like [LIST:token-holders]&lt;br /&gt;
# Set TOKEN:CNOOBS&lt;br /&gt;
# Define rates, e.g.:&lt;br /&gt;
** TEXT:1 CNOOBS&lt;br /&gt;
** VOICE:RING:5 CNOOBS;CONNECT:10 CNOOBS;RATE:20 CNOOBS/hr&lt;br /&gt;
* Your server will automatically verify balances using public Hive Engine RPC when someone tries to call or message you.&lt;br /&gt;
&lt;br /&gt;
Blocked users can bypass your blocklist if you set ALLOW-IF-TOKEN:CNOOBS and they hold enough.&lt;br /&gt;
&lt;br /&gt;
== Exchanging tokens on an exchange ==&lt;br /&gt;
Its pretty simple - you can exchange with hive-engine.com&lt;br /&gt;
* https://hive-engine.com/trade/CNOOBS&lt;br /&gt;
&amp;lt;code&amp;gt;https://hive-engine.com/trade/&amp;lt;YOUR_TOKEN&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Finding your tokens across the network ==&lt;br /&gt;
&lt;br /&gt;
* https://he.dtools.dev/richlist/CNOOBS&lt;br /&gt;
&lt;br /&gt;
== Common Problems and Fixes ==&lt;br /&gt;
=== &amp;quot;Not enough BEE&amp;quot; error ===&lt;br /&gt;
Buy or swap more BEE on TribalDex.&lt;br /&gt;
&lt;br /&gt;
=== Token symbol already taken ===&lt;br /&gt;
Choose a different symbol (add numbers or make it longer, e.g. CNOOBS2).&lt;br /&gt;
&lt;br /&gt;
=== Transaction fails ===&lt;br /&gt;
Make sure you are using the &#039;&#039;&#039;active key&#039;&#039;&#039; in Keychain for token creation/issuing. Restart Keychain if needed.&lt;br /&gt;
&lt;br /&gt;
=== Can&#039;t find my token after creation ===&lt;br /&gt;
Refresh the page or check https://hive-engine.com/tokens — it may take a few seconds to appear.&lt;br /&gt;
&lt;br /&gt;
=== Want to issue more later? ===&lt;br /&gt;
You can issue up to your max supply at any time from the manage page.&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Action !! Where to Do It&lt;br /&gt;
|-&lt;br /&gt;
| Create token || https://tribaldex.com/tokens/create&lt;br /&gt;
|-&lt;br /&gt;
| Manage / Issue tokens || https://tribaldex.com/tokens/manage&lt;br /&gt;
|-&lt;br /&gt;
| View all tokens || https://hive-engine.com/tokens&lt;br /&gt;
|-&lt;br /&gt;
| Swap BEE || TribalDex market&lt;br /&gt;
|-&lt;br /&gt;
| Check balance in Keychain || Hive Keychain extension → Tokens tab&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Next step for v4call users&#039;&#039;&#039;: After creating your token, update your v4call-rates post using the improved rate-editor.html to include your custom token rates and blocklist bypass.&lt;br /&gt;
&lt;br /&gt;
You now have your own personal communication currency on Hive!&lt;br /&gt;
&lt;br /&gt;
== Staking &amp;lt;b&amp;gt;UNTESTED&amp;lt;/b&amp;gt; at current time ==&lt;br /&gt;
== Appendix A: Enabling Staking on an Existing Token (e.g. CNOOBS) ==&lt;br /&gt;
So you&#039;ve created your token but forgot to enable staking? No problem — staking can be enabled on an existing Hive Engine token at any time. This appendix walks you through it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why enable staking?&#039;&#039;&#039;&lt;br /&gt;
* Lets holders &amp;quot;lock up&amp;quot; tokens for influence, rewards, or governance.&lt;br /&gt;
* Required if you ever want to add a reward pool (Scotbot/SMT) later — staking is a prerequisite.&lt;br /&gt;
* Useful in v4call: you can require holders to &#039;&#039;stake&#039;&#039; (not just hold) CNOOBS to bypass blocklists or unlock premium rates. Staked tokens can&#039;t be quickly dumped, so they prove genuine commitment.&lt;br /&gt;
* Adds scarcity — staked tokens are removed from the liquid circulating supply.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Cost&#039;&#039;&#039;: Approximately &#039;&#039;&#039;1000 BEE&#039;&#039;&#039; (one-time burn fee). This is significantly more than the 100 BEE token creation fee, so make sure you actually want this feature before paying. The fee is non-refundable.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important&#039;&#039;&#039;: Enabling staking is generally a one-way action. Once enabled it cannot be disabled. Some parameters (like cooldown period) can be edited later, but the staking feature itself is permanent. Plan your tokenomics before pulling the trigger.&lt;br /&gt;
&lt;br /&gt;
=== What You Need ===&lt;br /&gt;
* Your existing token (e.g. CNOOBS) created on Hive Engine.&lt;br /&gt;
* Your Hive account with Hive Keychain and the &#039;&#039;&#039;active key&#039;&#039;&#039; available.&lt;br /&gt;
* At least &#039;&#039;&#039;1010 BEE&#039;&#039;&#039; in your wallet (1000 for the fee + small buffer).&lt;br /&gt;
* A few minutes.&lt;br /&gt;
&lt;br /&gt;
=== Step A1: Get 1000+ BEE ===&lt;br /&gt;
Same as before — swap HIVE/HBD for BEE on [https://tribaldex.com tribaldex.com] or buy BEE on the Hive Engine market. Make sure you have at least 1010 BEE liquid in your wallet (not staked) before continuing.&lt;br /&gt;
&lt;br /&gt;
=== Step A2: Go to the Manage Tokens Page ===&lt;br /&gt;
# Visit [https://tribaldex.com/tokens/manage https://tribaldex.com/tokens/manage]&lt;br /&gt;
# Log in with Hive Keychain (active key) if not already logged in.&lt;br /&gt;
# Find your token (CNOOBS) in the list of tokens you own.&lt;br /&gt;
&lt;br /&gt;
=== Step A3: Click &amp;quot;Enable Staking&amp;quot; ===&lt;br /&gt;
# Next to your token, look for the &#039;&#039;&#039;Enable Staking&#039;&#039;&#039; button or icon (sometimes shown as a lock/coin symbol). Depending on UI updates, it may be under an &amp;quot;Actions&amp;quot; or &amp;quot;More&amp;quot; menu.&lt;br /&gt;
# A form will appear with the following fields:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Unstaking Cooldown (days)&#039;&#039;&#039; — How long it takes to unstake tokens once a holder requests it. Common values: 7, 14, 28, or 30 days. Longer = more commitment = more scarcity. For a personal communication token like CNOOBS, &#039;&#039;&#039;7 days&#039;&#039;&#039; is a reasonable starting point.&lt;br /&gt;
* &#039;&#039;&#039;Number of Transactions&#039;&#039;&#039; — How many payouts the unstake is split into. Example: 28 days over 4 transactions = the holder gets 25% of their unstaked tokens every 7 days. Common values: 1, 4, or matching the cooldown days.&lt;br /&gt;
* &#039;&#039;&#039;Enable Delegation&#039;&#039;&#039; (optional checkbox) — Lets users delegate (lend) staked tokens to others without unstaking. Highly recommended for v4call use cases (e.g. delegate CNOOBS to a friend so they can call you).&lt;br /&gt;
* &#039;&#039;&#039;Delegation Cooldown (days)&#039;&#039;&#039; (if delegation enabled) — How long after un-delegating before tokens return to the original staker. Usually 7 days.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Recommended starter settings for CNOOBS:&#039;&#039;&#039;&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Field !! Suggested Value&lt;br /&gt;
|-&lt;br /&gt;
| Unstaking Cooldown || 7 days&lt;br /&gt;
|-&lt;br /&gt;
| Number of Transactions || 4 (= 25% every ~1.75 days)&lt;br /&gt;
|-&lt;br /&gt;
| Enable Delegation || Yes (checked)&lt;br /&gt;
|-&lt;br /&gt;
| Delegation Cooldown || 7 days&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
# Double-check your settings — cooldown values can usually be edited later, but it costs additional small fees and confusion for holders.&lt;br /&gt;
# Click &#039;&#039;&#039;Enable Staking&#039;&#039;&#039; (or similar confirmation button).&lt;br /&gt;
# Hive Keychain will pop up asking you to approve a custom_json transaction that &#039;&#039;&#039;burns 1000 BEE&#039;&#039;&#039;. Confirm with your active key.&lt;br /&gt;
# Wait 3–10 seconds for blockchain confirmation.&lt;br /&gt;
&lt;br /&gt;
Success! Staking is now live on your token.&lt;br /&gt;
&lt;br /&gt;
=== Step A4: Verify Staking is Enabled ===&lt;br /&gt;
# Go to https://hive-engine.com/tokens&lt;br /&gt;
# Search for CNOOBS.&lt;br /&gt;
# Click the token — you should now see a &amp;quot;Stake&amp;quot; / &amp;quot;Unstake&amp;quot; / &amp;quot;Delegate&amp;quot; option visible to all holders.&lt;br /&gt;
# In your own Hive Keychain wallet, on the Tokens tab, CNOOBS should now show a small lock/stake icon next to the transfer button.&lt;br /&gt;
&lt;br /&gt;
Try staking 10 CNOOBS yourself as a test — it should appear under &amp;quot;Staked Balance&amp;quot; immediately.&lt;br /&gt;
&lt;br /&gt;
=== Step A5: Update v4call to Recognise Staked Tokens ===&lt;br /&gt;
Your v4call rate editor can distinguish between &#039;&#039;&#039;liquid&#039;&#039;&#039; and &#039;&#039;&#039;staked&#039;&#039;&#039; balances. Examples in V2 format:&lt;br /&gt;
&lt;br /&gt;
[LIST:liquid-holders]&lt;br /&gt;
TOKEN:CNOOBS&lt;br /&gt;
TYPE:LIQUID&lt;br /&gt;
TEXT:1 CNOOBS&lt;br /&gt;
&lt;br /&gt;
[LIST:committed-holders]&lt;br /&gt;
TOKEN:CNOOBS&lt;br /&gt;
TYPE:STAKED&lt;br /&gt;
MIN:100&lt;br /&gt;
TEXT:FREE&lt;br /&gt;
VOICE:FREE&lt;br /&gt;
ALLOW-IF-STAKED:CNOOBS:100&lt;br /&gt;
&lt;br /&gt;
This means: anyone holding 100+ &#039;&#039;&#039;staked&#039;&#039;&#039; CNOOBS gets free messaging and bypasses the blocklist, while liquid holders pay normal rates. Staking proves long-term commitment, not just speculation.&lt;br /&gt;
&lt;br /&gt;
=== Common Problems and Fixes (Staking) ===&lt;br /&gt;
&lt;br /&gt;
==== &amp;quot;Insufficient BEE balance&amp;quot; ====&lt;br /&gt;
You need 1000+ BEE liquid (not staked). Buy/swap more on TribalDex.&lt;br /&gt;
&lt;br /&gt;
==== &amp;quot;Enable Staking&amp;quot; button doesn&#039;t appear ====&lt;br /&gt;
* Make sure you&#039;re logged in as the token issuer (the account that created CNOOBS).&lt;br /&gt;
* Try a different Hive Engine RPC node — go to TribalDex settings and switch to a different node. Some nodes lag.&lt;br /&gt;
* Refresh and try again.&lt;br /&gt;
&lt;br /&gt;
==== Transaction succeeds but staking doesn&#039;t activate ====&lt;br /&gt;
Wait 30–60 seconds and refresh — Hive Engine sometimes takes a moment to propagate. Check https://hive-engine.com/tokens to confirm.&lt;br /&gt;
&lt;br /&gt;
==== Can I disable staking later? ====&lt;br /&gt;
Generally no — staking is a permanent feature once enabled. You &#039;&#039;can&#039;&#039; edit the cooldown period and delegation settings later (small Keychain transaction, no big fee), but the staking system itself stays on.&lt;br /&gt;
&lt;br /&gt;
==== Can I add a reward pool now? ====&lt;br /&gt;
Yes — with staking enabled, you can now optionally pay another 1000 BEE for Scotbot/SMT to enable post curation rewards in CNOOBS. Not required for v4call use, but available if you want to grow a community around the token.&lt;br /&gt;
&lt;br /&gt;
=== Updated Quick Reference ===&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Action !! Cost (BEE) !! Where&lt;br /&gt;
|-&lt;br /&gt;
| Create token || 100 || tribaldex.com/tokens/create&lt;br /&gt;
|-&lt;br /&gt;
| Issue tokens || Free (gas only) || tribaldex.com/tokens/manage&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Enable staking&#039;&#039;&#039; || &#039;&#039;&#039;1000&#039;&#039;&#039; || &#039;&#039;&#039;tribaldex.com/tokens/manage&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| Edit staking cooldown || ~Free (small fee) || tribaldex.com/tokens/manage&lt;br /&gt;
|-&lt;br /&gt;
| Enable Scotbot rewards (optional) || 1000 || tribaldex.com/smt/manage&lt;br /&gt;
|-&lt;br /&gt;
| View token info || Free || hive-engine.com/tokens&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Total cost so far for a staking-enabled CNOOBS&#039;&#039;&#039;: ~1100 BEE (100 create + 1000 staking).&lt;br /&gt;
&lt;br /&gt;
You now have a fully stakeable communication token — perfect for distinguishing committed v4call users from casual holders.&lt;br /&gt;
&lt;br /&gt;
[[Category:Hive]]&lt;br /&gt;
[[Category:Hive Engine]]&lt;br /&gt;
[[Category:v4call]]&lt;br /&gt;
[[Category:Token Creation]]&lt;br /&gt;
[[Category:Web3]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=NGate&amp;diff=761</id>
		<title>NGate</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=NGate&amp;diff=761"/>
		<updated>2026-05-26T00:40:51Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;* nGate-dev&lt;br /&gt;
&lt;br /&gt;
Stage 1: https://www.completenoobs.com/noobs/Nostr-relay-with-whitelist&lt;br /&gt;
&lt;br /&gt;
Stage 2: https://www.completenoobs.com/noobs/Nostr-handson&lt;br /&gt;
&lt;br /&gt;
Data: https://www.completenoobs.com/noobs/V4call-server-data-flow&lt;br /&gt;
&lt;br /&gt;
https://www.completenoobs.com/noobs/Ngate_basic&lt;br /&gt;
&lt;br /&gt;
https://www.completenoobs.com/noobs/Ngate-strfry-basic&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Ngate-strfry-basic&amp;diff=760</id>
		<title>Ngate-strfry-basic</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Ngate-strfry-basic&amp;diff=760"/>
		<updated>2026-05-26T00:40:18Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;{{:LICENCE_HEADER_MIT}}   = strfry + nGate — A Hot-Reloading Whitelisted Nostr Relay on Ubuntu 24.04 =  From CompleteNoobs  This guide deploys a &amp;#039;&amp;#039;&amp;#039;strfry&amp;#039;&amp;#039;&amp;#039; Nostr relay at &amp;lt;code&amp;gt;nostr.v4call.com&amp;lt;/code&amp;gt; on a fresh Ubuntu 24.04 server, behind TLS, with nGate driving a &amp;#039;&amp;#039;&amp;#039;live, hot-reloading whitelist&amp;#039;&amp;#039;&amp;#039; — the relay&amp;#039;s allowed-publisher list is updated &amp;#039;&amp;#039;&amp;#039;without ever restarting the relay&amp;#039;&amp;#039;&amp;#039;.  This is the &amp;quot;Stage 4&amp;quot; path described in CLAUDE.md / STATUS.md. It is...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_MIT}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= strfry + nGate — A Hot-Reloading Whitelisted Nostr Relay on Ubuntu 24.04 =&lt;br /&gt;
&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
&lt;br /&gt;
This guide deploys a &#039;&#039;&#039;strfry&#039;&#039;&#039; Nostr relay at &amp;lt;code&amp;gt;nostr.v4call.com&amp;lt;/code&amp;gt; on a fresh Ubuntu 24.04 server, behind TLS, with nGate driving a &#039;&#039;&#039;live, hot-reloading whitelist&#039;&#039;&#039; — the relay&#039;s allowed-publisher list is updated &#039;&#039;&#039;without ever restarting the relay&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
This is the &amp;quot;Stage 4&amp;quot; path described in [[CLAUDE.md]] / [[STATUS.md]]. It is an &#039;&#039;&#039;alternative&#039;&#039;&#039; to the [[#nostr-relay-with-whitelist|nostr-rs-relay]] stage-1 deploy, not a replacement you must take. Read the comparison below before deciding.&lt;br /&gt;
&lt;br /&gt;
This is a &amp;quot;noob doing Nostr by doing&amp;quot; walkthrough in the same style as the [[Nostr Relay With a 3-Key Whitelist]] guide. Every command can be copy-pasted exactly as shown. You do not need to know C++, Go, or Rust.&lt;br /&gt;
&lt;br /&gt;
== Why strfry instead of nostr-rs-relay? ==&lt;br /&gt;
&lt;br /&gt;
Both work. The difference is &#039;&#039;&#039;how the whitelist is updated&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! !! nostr-rs-relay (stage 1) !! strfry (this guide)&lt;br /&gt;
|-&lt;br /&gt;
| Whitelist lives in || &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;pubkey_whitelist = [...]&amp;lt;/code&amp;gt; || a separate plain file the policy plugin reads&lt;br /&gt;
|-&lt;br /&gt;
| To change the whitelist || rewrite &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; &#039;&#039;&#039;+ restart the container&#039;&#039;&#039; || rewrite the whitelist file — &#039;&#039;&#039;no restart&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| nGate apply phase || writes config.toml between BEGIN/END markers, runs &amp;lt;code&amp;gt;docker restart&amp;lt;/code&amp;gt; || writes &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt; atomically; relay picks it up on the next event&lt;br /&gt;
|-&lt;br /&gt;
| Restart cap / sanity bound || needed (a restart loop would flap the relay) || restart cap becomes irrelevant — there is no restart&lt;br /&gt;
|-&lt;br /&gt;
| Downtime on whitelist change || ~1–3 s reconnect blip for every connected client || none — open connections stay up&lt;br /&gt;
|-&lt;br /&gt;
| Config format || TOML || strfry&#039;s own libconfig-style &amp;lt;code&amp;gt;strfry.conf&amp;lt;/code&amp;gt; (NOT TOML)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;The headline:&#039;&#039;&#039; a strfry &#039;&#039;&#039;write-policy plugin&#039;&#039;&#039; is a small program strfry runs and pipes every incoming event to. The plugin decides accept/reject per event. Because the plugin re-reads the whitelist file on each event (cheap — it is tiny), the moment nGate rewrites that file the &#039;&#039;&#039;very next event&#039;&#039;&#039; is judged against the new list. No restart, no dropped client connections.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;The tradeoff (be honest):&#039;&#039;&#039;&lt;br /&gt;
* The plugin runs &#039;&#039;&#039;once per inbound write event&#039;&#039;&#039;. It must be fast. Reading a small JSON file per event is fine; we add an mtime cache so it only re-parses when the file actually changes.&lt;br /&gt;
* strfry&#039;s config file is &#039;&#039;&#039;not&#039;&#039;&#039; TOML. If you already run nostr-rs-relay you cannot reuse &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; — this is a parallel setup.&lt;br /&gt;
* nGate&#039;s &amp;lt;code&amp;gt;scan → verify → gate&amp;lt;/code&amp;gt; phases are &#039;&#039;&#039;unchanged&#039;&#039;&#039;. Only the &amp;lt;code&amp;gt;apply&amp;lt;/code&amp;gt; phase swaps backend (writes a whitelist file instead of config.toml + restart). This guide ships a small &amp;lt;code&amp;gt;ngate-strfry-apply.sh&amp;lt;/code&amp;gt; for that; the 3-strike-miss / state machinery from &amp;lt;code&amp;gt;ngate-apply.sh&amp;lt;/code&amp;gt; is a later port (see [[#Whats_deferred|What&#039;s deferred]]).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Answering the question &amp;quot;can strfry be a Docker image?&amp;quot;&#039;&#039;&#039; — &#039;&#039;&#039;Yes.&#039;&#039;&#039; strfry ships an official multi-stage &amp;lt;code&amp;gt;Dockerfile&amp;lt;/code&amp;gt; in its source repo. You &amp;lt;code&amp;gt;docker build&amp;lt;/code&amp;gt; it once on the box (Step 7) and run it from &amp;lt;code&amp;gt;docker compose&amp;lt;/code&amp;gt; like any other service. There are also community-published images on registries; we build from source instead so you are running code you fetched from the official repo at a tag you chose (trust tradeoff — a prebuilt image is faster but you are trusting whoever pushed it).&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
&lt;br /&gt;
* [[#What_You_Need|1 What You Need]]&lt;br /&gt;
* [[#Step_1:_Create_and_Point_the_Server|2 Step 1: Create and Point the Server]]&lt;br /&gt;
* [[#Step_2:_Log_In_and_Harden|3 Step 2: Log In and Harden]]&lt;br /&gt;
* [[#Step_3:_Install_Docker|4 Step 3: Install Docker]]&lt;br /&gt;
* [[#Step_4:_Open_the_Firewall|5 Step 4: Open the Firewall]]&lt;br /&gt;
* [[#Step_5:_Project_Folder_Layout|6 Step 5: Project Folder Layout]]&lt;br /&gt;
* [[#Step_6:_Get_Your_Whitelisted_Keys_in_Hex|7 Step 6: Get Your Whitelisted Keys in Hex]]&lt;br /&gt;
* [[#Step_7:_Build_the_strfry_Docker_Image|8 Step 7: Build the strfry Docker Image]]&lt;br /&gt;
* [[#Step_8:_Write_strfry.conf|9 Step 8: Write strfry.conf]]&lt;br /&gt;
* [[#Step_9:_The_Write-Policy_Plugin|10 Step 9: The Write-Policy Plugin]]&lt;br /&gt;
* [[#Step_10:_Seed_the_Whitelist|11 Step 10: Seed the Whitelist]]&lt;br /&gt;
* [[#Step_11:_Caddy_for_TLS|12 Step 11: Caddy for TLS]]&lt;br /&gt;
* [[#Step_12:_docker-compose.yml|13 Step 12: docker-compose.yml]]&lt;br /&gt;
* [[#Step_13:_Start_It_Up|14 Step 13: Start It Up]]&lt;br /&gt;
* [[#Step_14:_Prove_the_Whitelist_Works|15 Step 14: Prove the Whitelist Works]]&lt;br /&gt;
* [[#Step_15:_Prove_Hot-Reload_Works|16 Step 15: Prove Hot-Reload Works (the whole point)]]&lt;br /&gt;
* [[#Step_16:_Wire_in_nGate|17 Step 16: Wire in nGate]]&lt;br /&gt;
* [[#Step_17:_Automate_It_with_cron|18 Step 17: Automate It with cron]]&lt;br /&gt;
* [[#Step_18:_Optional_.E2.80.94_Per-User_Whitelisting_via_nostr-announce|19 Step 18: Optional — Per-User Whitelisting via &amp;lt;code&amp;gt;nostr-announce&amp;lt;/code&amp;gt;]]&lt;br /&gt;
* [[#Common_Problems_and_Fixes|20 Common Problems and Fixes]]&lt;br /&gt;
* [[#Whats_deferred|21 What&#039;s deferred]]&lt;br /&gt;
* [[#Tips_for_Noobs|22 Tips for Noobs]]&lt;br /&gt;
&lt;br /&gt;
== What You Need ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;A VPS&#039;&#039;&#039; running fresh &#039;&#039;&#039;Ubuntu 24.04&#039;&#039;&#039;, 1 GB RAM minimum (2 GB comfortable — the strfry build is C++ and wants RAM to compile). Vultr, Hetzner, DigitalOcean, etc. If using Vultr, our [https://www.vultr.com/?ref=7704739 Vultr referral link] helps cover server costs.&lt;br /&gt;
* &#039;&#039;&#039;A domain/subdomain&#039;&#039;&#039; with DNS access — this guide uses &amp;lt;code&amp;gt;nostr.v4call.com&amp;lt;/code&amp;gt;. Substitute your own everywhere you see it.&lt;br /&gt;
* &#039;&#039;&#039;Your whitelisted Nostr keys in hex&#039;&#039;&#039; (Step 6 shows conversion). At minimum: the operator&#039;s own key, so you don&#039;t lock yourself out.&lt;br /&gt;
* &#039;&#039;&#039;A terminal + SSH&#039;&#039;&#039; and about &#039;&#039;&#039;45–60 minutes&#039;&#039;&#039; (the strfry compile is the slow part — 5–15 min depending on the box).&lt;br /&gt;
* &#039;&#039;&#039;The nGate repo&#039;&#039;&#039; if you want the automated whitelist (Step 16). Without nGate this is still a perfectly good manually-managed hot-reload relay.&lt;br /&gt;
&lt;br /&gt;
== Step 1: Create and Point the Server ==&lt;br /&gt;
&lt;br /&gt;
# Create the Ubuntu 24.04 VPS in your provider&#039;s panel. Note its public IPv4 address.&lt;br /&gt;
# In your DNS provider, add an &#039;&#039;&#039;A record&#039;&#039;&#039;: &amp;lt;code&amp;gt;nostr.v4call.com → &amp;amp;lt;your VPS IP&amp;amp;gt;&amp;lt;/code&amp;gt;. TTL 300 is fine.&lt;br /&gt;
# Wait for DNS to propagate. Check from your laptop:&lt;br /&gt;
&lt;br /&gt;
 dig +short nostr.v4call.com&lt;br /&gt;
&lt;br /&gt;
When that prints your VPS IP, continue. (Caddy in Step 11 will not be able to get a TLS certificate until DNS resolves correctly, so don&#039;t skip this.)&lt;br /&gt;
&lt;br /&gt;
== Step 2: Log In and Harden ==&lt;br /&gt;
&lt;br /&gt;
 ssh root@nostr.v4call.com&lt;br /&gt;
&lt;br /&gt;
Basic hardening (same as the stage-1 guide — skip if you already have your own baseline):&lt;br /&gt;
&lt;br /&gt;
 apt update &amp;amp;&amp;amp; apt upgrade -y&lt;br /&gt;
 adduser noob&lt;br /&gt;
 usermod -aG sudo noob&lt;br /&gt;
 rsync --archive --chown=noob:noob ~/.ssh /home/noob&lt;br /&gt;
 # then log out and back in as: ssh noob@nostr.v4call.com&lt;br /&gt;
&lt;br /&gt;
From here on, run commands as the &amp;lt;code&amp;gt;noob&amp;lt;/code&amp;gt; user with &amp;lt;code&amp;gt;sudo&amp;lt;/code&amp;gt; where shown.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Install Docker ==&lt;br /&gt;
&lt;br /&gt;
 sudo apt update&lt;br /&gt;
 sudo apt install -y ca-certificates curl git&lt;br /&gt;
 sudo install -m 0755 -d /etc/apt/keyrings&lt;br /&gt;
 sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc&lt;br /&gt;
 sudo chmod a+r /etc/apt/keyrings/docker.asc&lt;br /&gt;
 echo &amp;quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release &amp;amp;&amp;amp; echo $VERSION_CODENAME) stable&amp;quot; | sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null&lt;br /&gt;
 sudo apt update&lt;br /&gt;
 sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin&lt;br /&gt;
 sudo usermod -aG docker $USER&lt;br /&gt;
 newgrp docker&lt;br /&gt;
&lt;br /&gt;
Verify:&lt;br /&gt;
&lt;br /&gt;
 docker --version&lt;br /&gt;
 docker compose version&lt;br /&gt;
&lt;br /&gt;
== Step 4: Open the Firewall ==&lt;br /&gt;
&lt;br /&gt;
 sudo ufw allow OpenSSH&lt;br /&gt;
 sudo ufw allow 80/tcp&lt;br /&gt;
 sudo ufw allow 443/tcp&lt;br /&gt;
 sudo ufw --force enable&lt;br /&gt;
 sudo ufw status&lt;br /&gt;
&lt;br /&gt;
Only 22 (SSH), 80 and 443 are exposed. strfry&#039;s own port (7777) stays internal to the Docker network — Caddy is the only thing the public talks to.&lt;br /&gt;
&lt;br /&gt;
== Step 5: Project Folder Layout ==&lt;br /&gt;
&lt;br /&gt;
 sudo mkdir -p /opt/nostr-relay&lt;br /&gt;
 sudo chown -R $USER:$USER /opt/nostr-relay&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 mkdir -p strfry-db policy caddy_data caddy_config&lt;br /&gt;
&lt;br /&gt;
You will end up with:&lt;br /&gt;
&lt;br /&gt;
 /opt/nostr-relay/&lt;br /&gt;
 ├── docker-compose.yml          (Step 12)&lt;br /&gt;
 ├── strfry.conf                 (Step 8)&lt;br /&gt;
 ├── Caddyfile                   (Step 11)&lt;br /&gt;
 ├── strfry/                     (the cloned strfry source, for the image build — Step 7)&lt;br /&gt;
 ├── policy/&lt;br /&gt;
 │   ├── whitelist-policy.sh     (the write-policy plugin — Step 9)&lt;br /&gt;
 │   └── whitelist.json          (the live whitelist nGate rewrites — Step 10)&lt;br /&gt;
 ├── seed.toml                   (operator&#039;s always-allowed keys — Step 10)&lt;br /&gt;
 ├── strfry-db/                  (LMDB event database — created by strfry)&lt;br /&gt;
 ├── caddy_data/  caddy_config/  (Caddy TLS state)&lt;br /&gt;
 └── (nGate bits added in Step 16)&lt;br /&gt;
&lt;br /&gt;
== Step 6: Get Your Whitelisted Keys in Hex ==&lt;br /&gt;
&lt;br /&gt;
Nostr relays compare the &#039;&#039;&#039;hex&#039;&#039;&#039; form of a public key, not the bech32 &amp;lt;code&amp;gt;npub1...&amp;lt;/code&amp;gt; form. Same key, different encoding. The whitelist file holds hex.&lt;br /&gt;
&lt;br /&gt;
To convert an npub to hex, the easiest no-install option: open &amp;lt;code&amp;gt;public/nostr-gen.html&amp;lt;/code&amp;gt; from the v4call repo in any browser — it has an npub↔hex converter and never sends anything over the network. Or with the &amp;lt;code&amp;gt;nak&amp;lt;/code&amp;gt; CLI:&lt;br /&gt;
&lt;br /&gt;
 nak decode npub1yourkeyhere&lt;br /&gt;
&lt;br /&gt;
Write down each whitelisted key&#039;s 64-character hex string. You need at least the &#039;&#039;&#039;operator&#039;s own key&#039;&#039;&#039; before first start, or you lock yourself out (same rule as nGate&#039;s &amp;lt;code&amp;gt;seed.toml&amp;lt;/code&amp;gt; — see [[CLAUDE.md]] decision #6).&lt;br /&gt;
&lt;br /&gt;
== Step 7: Build the strfry Docker Image ==&lt;br /&gt;
&lt;br /&gt;
We build from the official source at a pinned tag so you know exactly what you are running.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
&#039;&#039;&#039;Useful info for low-powered VPS builds — read this BEFORE you run &amp;lt;code&amp;gt;docker build&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The strfry compile is C++ (&amp;lt;code&amp;gt;g++ -std=c++20 -O3&amp;lt;/code&amp;gt;) and it is the single most resource-hungry thing in this whole guide. On a small VPS it can make the box go &#039;&#039;&#039;completely unresponsive&#039;&#039;&#039; — you can&#039;t even open a second SSH session. Here is what is actually happening and how to survive it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;It&#039;s RAM, not &amp;quot;CPU at 100%&amp;quot;.&#039;&#039;&#039; Linux schedules CPU fairly, so a pegged CPU still lets &amp;lt;code&amp;gt;sshd&amp;lt;/code&amp;gt; in (just slowly). What truly freezes a tiny box is &#039;&#039;&#039;memory exhaustion&#039;&#039;&#039;: each &amp;lt;code&amp;gt;g++&amp;lt;/code&amp;gt; process on strfry can need &#039;&#039;&#039;1–1.5 GB&#039;&#039;&#039;, and strfry&#039;s Dockerfile compiles several files in parallel. On a 1 GB VPS with no swap you hit the OOM killer / heavy swapping, and that is what blocks new logins. &#039;&#039;&#039;The build looks frozen but usually isn&#039;t&#039;&#039;&#039; — a single &amp;lt;code&amp;gt;main.cpp&amp;lt;/code&amp;gt; object can take minutes with no output. Don&#039;t &amp;lt;code&amp;gt;Ctrl-C&amp;lt;/code&amp;gt; too early (the way you almost did).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;There is no &amp;quot;limit build to 70% CPU&amp;quot; flag.&#039;&#039;&#039; This guide&#039;s build uses BuildKit (you&#039;ll see &amp;lt;code&amp;gt;[+] Building&amp;lt;/code&amp;gt; with &amp;lt;code&amp;gt;docker:default&amp;lt;/code&amp;gt;). BuildKit &#039;&#039;&#039;ignores&#039;&#039;&#039; &amp;lt;code&amp;gt;--cpu-quota&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;--cpus&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;--cpuset-cpus&amp;lt;/code&amp;gt; during &amp;lt;code&amp;gt;docker build&amp;lt;/code&amp;gt; — those flags only apply to &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; (runtime containers), not to the build. &amp;lt;code&amp;gt;nice docker build ...&amp;lt;/code&amp;gt; also does nothing useful, because the compiler processes are children of &amp;lt;code&amp;gt;dockerd&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;containerd&amp;lt;/code&amp;gt;, not of your &amp;lt;code&amp;gt;docker&amp;lt;/code&amp;gt; CLI, so the renice doesn&#039;t propagate. The real levers are &#039;&#039;&#039;parallelism&#039;&#039;&#039; and &#039;&#039;&#039;swap&#039;&#039;&#039;, not a CPU percentage.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Lever 1 — lower the compile parallelism (biggest win).&#039;&#039;&#039; strfry&#039;s &amp;lt;code&amp;gt;Dockerfile&amp;lt;/code&amp;gt; contains a line like:&lt;br /&gt;
&lt;br /&gt;
 RUN --mount=type=cache,target=/build/.cache \&lt;br /&gt;
     make -j4&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;-j4&amp;lt;/code&amp;gt; = up to 4 compilers at once = up to ~4–6 GB peak RAM. Edit it down before building:&lt;br /&gt;
* &amp;lt;code&amp;gt;make -j1&amp;lt;/code&amp;gt; — serial. Slowest, but a 1 GB box survives it. Safest on the smallest VPS.&lt;br /&gt;
* &amp;lt;code&amp;gt;make -j2&amp;lt;/code&amp;gt; — a sane middle ground if you have ≥2 GB RAM.&lt;br /&gt;
&lt;br /&gt;
This one edit is what turns &amp;quot;box OOM-kills the compiler / freezes&amp;quot; into &amp;quot;slow but it finishes&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Lever 2 — add swap BEFORE you build.&#039;&#039;&#039; Even 2 GB of swap turns &amp;quot;unresponsive / OOM&amp;quot; into &amp;quot;sluggish but still loggable, and the build completes&amp;quot;. The swap commands are right after this box — run them &#039;&#039;&#039;first&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
 sudo fallocate -l 2G /swapfile &amp;amp;&amp;amp; sudo chmod 600 /swapfile &amp;amp;&amp;amp; sudo mkswap /swapfile &amp;amp;&amp;amp; sudo swapon /swapfile&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Lever 3 — never lose your session.&#039;&#039;&#039; Start the build inside &amp;lt;code&amp;gt;tmux&amp;lt;/code&amp;gt; (or &amp;lt;code&amp;gt;screen&amp;lt;/code&amp;gt;) so a dropped connection or a lock-up doesn&#039;t kill the build or lock you out:&lt;br /&gt;
&lt;br /&gt;
 tmux new -s build      # then run the build inside it&lt;br /&gt;
 # Ctrl-b then d  to detach;  tmux attach -t build  to come back&lt;br /&gt;
&lt;br /&gt;
Also open your &#039;&#039;&#039;second SSH session before&#039;&#039;&#039; starting the build, not after — once the box is under memory pressure you may not be able to log in fresh.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Watch the right thing.&#039;&#039;&#039; In the second session run &amp;lt;code&amp;gt;htop&amp;lt;/code&amp;gt; (&amp;lt;code&amp;gt;sudo apt install -y htop&amp;lt;/code&amp;gt;) and watch the &#039;&#039;&#039;Mem / Swp&#039;&#039;&#039; bars and swap in/out, not just CPU%. Swap churning = it&#039;s RAM = the fix is lower &amp;lt;code&amp;gt;-jN&amp;lt;/code&amp;gt; + more swap, exactly as above.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;TL;DR for a small box:&#039;&#039;&#039; add swap → edit &amp;lt;code&amp;gt;make -j4&amp;lt;/code&amp;gt; down to &amp;lt;code&amp;gt;-j1&amp;lt;/code&amp;gt; (or &amp;lt;code&amp;gt;-j2&amp;lt;/code&amp;gt; if ≥2 GB) → run the build inside &amp;lt;code&amp;gt;tmux&amp;lt;/code&amp;gt; → watch &amp;lt;code&amp;gt;htop&amp;lt;/code&amp;gt;&#039;s memory line → be patient, a quiet build is normal.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 git clone https://github.com/hoytech/strfry.git strfry&lt;br /&gt;
 cd strfry&lt;br /&gt;
 git submodule update --init&lt;br /&gt;
 # Pin to a release tag (check https://github.com/hoytech/strfry/releases for the latest)&lt;br /&gt;
 git checkout 1.0.4&lt;br /&gt;
 docker build -t strfry:1.0.4 .&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
&lt;br /&gt;
This compiles strfry inside Docker. It is the slow step — &#039;&#039;&#039;5–15 minutes&#039;&#039;&#039; and a chunk of RAM. If the build is killed (OOM) on a 1 GB box, add swap first:&lt;br /&gt;
&lt;br /&gt;
 sudo fallocate -l 2G /swapfile&lt;br /&gt;
 sudo chmod 600 /swapfile&lt;br /&gt;
 sudo mkswap /swapfile&lt;br /&gt;
 sudo swapon /swapfile&lt;br /&gt;
&lt;br /&gt;
then re-run &amp;lt;code&amp;gt;docker build&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Now build the nGate-flavoured image (REQUIRED — do not skip).&#039;&#039;&#039; The official strfry image is built &amp;lt;code&amp;gt;FROM alpine&amp;lt;/code&amp;gt;, which ships busybox &amp;lt;code&amp;gt;sh&amp;lt;/code&amp;gt; only — &#039;&#039;&#039;no &amp;lt;code&amp;gt;bash&amp;lt;/code&amp;gt;, no &amp;lt;code&amp;gt;jq&amp;lt;/code&amp;gt;&#039;&#039;&#039;. The write-policy plugin in Step 9 needs both. If you run the stock &amp;lt;code&amp;gt;strfry:1.0.4&amp;lt;/code&amp;gt; image, the relay starts and serves reads fine, but &#039;&#039;&#039;every publish fails with &amp;lt;code&amp;gt;error: internal error&amp;lt;/code&amp;gt;&#039;&#039;&#039; because the plugin can&#039;t execute (you&#039;d waste an hour finding that out — we did). Bake the two tools in with a three-line wrapper image. Create &amp;lt;code&amp;gt;/opt/nostr-relay/Dockerfile.relay&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
FROM strfry:1.0.4&lt;br /&gt;
RUN apk add --no-cache bash jq&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Build it:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 docker build -t strfry-ngate:1.0.4 -f Dockerfile.relay .&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;From here on the relay image you run is &amp;lt;code&amp;gt;strfry-ngate:1.0.4&amp;lt;/code&amp;gt;&#039;&#039;&#039; (the compose file in Step 12 uses it). The plain &amp;lt;code&amp;gt;strfry:1.0.4&amp;lt;/code&amp;gt; image only exists as the base for this wrapper. (&amp;lt;code&amp;gt;busybox stat -c %Y&amp;lt;/code&amp;gt; — which the plugin also uses — &#039;&#039;&#039;is&#039;&#039;&#039; supported by Alpine&#039;s busybox, so once &amp;lt;code&amp;gt;bash&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;jq&amp;lt;/code&amp;gt; are present the Step 9 plugin runs unmodified.)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Shortcut (optional, trust tradeoff):&#039;&#039;&#039; community-prebuilt images exist on container registries. Using one skips the compile but means trusting whoever pushed it. For a relay that gates who can write, building from the official repo yourself is the safer default. If you do use a prebuilt image, pin it by digest, not &amp;lt;code&amp;gt;:latest&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Step 8: Write strfry.conf ==&lt;br /&gt;
&lt;br /&gt;
strfry&#039;s config is &#039;&#039;&#039;not TOML&#039;&#039;&#039; — it is a libconfig-style format. Create &amp;lt;code&amp;gt;/opt/nostr-relay/strfry.conf&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# strfry.conf — nostr.v4call.com&lt;br /&gt;
db = &amp;quot;/app/strfry-db/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
dbParams {&lt;br /&gt;
    maxreaders = 256&lt;br /&gt;
    mapsize = 10995116277760&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
relay {&lt;br /&gt;
    bind = &amp;quot;0.0.0.0&amp;quot;&lt;br /&gt;
    port = 7777&lt;br /&gt;
&lt;br /&gt;
    # strfry tries to raise its open-file limit to 1,000,000 by default.&lt;br /&gt;
    # Most VPS hosts cap well below that, so strfry aborts on startup&lt;br /&gt;
    # (&amp;quot;Unable to set NOFILES limit ...&amp;quot;) and crash-loops. 0 = &amp;quot;don&#039;t&lt;br /&gt;
    # attempt to set the limit, inherit the container&#039;s&amp;quot; — host-independent&lt;br /&gt;
    # and the right default here. THIS KEY MUST BE INSIDE relay { } —&lt;br /&gt;
    # at the top level strfry silently ignores it (see Common Problems).&lt;br /&gt;
    nofiles = 0&lt;br /&gt;
&lt;br /&gt;
    # We are behind Caddy; Caddy sets X-Forwarded-For. realIpHeader lets&lt;br /&gt;
    # strfry log the true client IP instead of the docker network IP.&lt;br /&gt;
    realIpHeader = &amp;quot;x-forwarded-for&amp;quot;&lt;br /&gt;
&lt;br /&gt;
    info {&lt;br /&gt;
        name = &amp;quot;nostr.v4call.com&amp;quot;&lt;br /&gt;
        description = &amp;quot;Whitelisted relay — write-gated by nGate, public read.&amp;quot;&lt;br /&gt;
        pubkey = &amp;quot;&amp;quot;&lt;br /&gt;
        contact = &amp;quot;&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    maxWebsocketPayloadSize = 131072&lt;br /&gt;
    autoPingSeconds = 55&lt;br /&gt;
&lt;br /&gt;
    # ── The bit that matters ──────────────────────────────────────────&lt;br /&gt;
    # writePolicy.plugin points at an executable strfry pipes every&lt;br /&gt;
    # inbound EVENT to (one JSON object per line on stdin; the plugin&lt;br /&gt;
    # replies with one JSON object per line on stdout). The plugin reads&lt;br /&gt;
    # the live whitelist file on each event, so rewriting that file&lt;br /&gt;
    # changes the policy WITHOUT restarting strfry.&lt;br /&gt;
    writePolicy {&lt;br /&gt;
        plugin = &amp;quot;/app/policy/whitelist-policy.sh&amp;quot;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Notes:&lt;br /&gt;
* &amp;lt;code&amp;gt;db&amp;lt;/code&amp;gt;, plugin path etc. are &#039;&#039;&#039;container-internal&#039;&#039;&#039; paths (&amp;lt;code&amp;gt;/app/...&amp;lt;/code&amp;gt;). The compose file in Step 12 bind-mounts host folders onto them.&lt;br /&gt;
* Changing &amp;lt;code&amp;gt;strfry.conf&amp;lt;/code&amp;gt; itself (e.g. the plugin &#039;&#039;&#039;path&#039;&#039;&#039;) &#039;&#039;&#039;does&#039;&#039;&#039; need a strfry restart — but the whitelist &#039;&#039;&#039;data&#039;&#039;&#039; the plugin reads does not. That distinction is the whole point: config path = static, whitelist data = live.&lt;br /&gt;
&lt;br /&gt;
== Step 9: The Write-Policy Plugin ==&lt;br /&gt;
&lt;br /&gt;
Create &amp;lt;code&amp;gt;/opt/nostr-relay/policy/whitelist-policy.sh&amp;lt;/code&amp;gt;. This is the program strfry runs. It reads one JSON line per event on stdin and must print one JSON line per event on stdout: &amp;lt;code&amp;gt;{&amp;quot;id&amp;quot;:&amp;quot;&amp;amp;lt;event id&amp;amp;gt;&amp;quot;,&amp;quot;action&amp;quot;:&amp;quot;accept&amp;quot;|&amp;quot;reject&amp;quot;,&amp;quot;msg&amp;quot;:&amp;quot;...&amp;quot;}&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
It reads &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt; (a JSON array of hex pubkeys) fresh — but only re-parses when the file&#039;s mtime changes, so the hot path is a cheap stat, not a re-read, per event.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Prerequisite:&#039;&#039;&#039; this script needs &amp;lt;code&amp;gt;bash&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;jq&amp;lt;/code&amp;gt; &#039;&#039;&#039;inside the relay container&#039;&#039;&#039;. Stock strfry (Alpine) has neither — that is exactly why Step 7 builds the &amp;lt;code&amp;gt;strfry-ngate:1.0.4&amp;lt;/code&amp;gt; wrapper image. If publishes fail with &amp;lt;code&amp;gt;error: internal error&amp;lt;/code&amp;gt;, you skipped that. Two filenames that are easy to confuse: &amp;lt;code&amp;gt;whitelist-policy.sh&amp;lt;/code&amp;gt; is &#039;&#039;&#039;this script&#039;&#039;&#039; (the program strfry runs); &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt; is the &#039;&#039;&#039;data file&#039;&#039;&#039; it reads (Step 10, rewritten by nGate). There is no &amp;lt;code&amp;gt;whitelist-policy.json&amp;lt;/code&amp;gt; — if you find one it&#039;s a stray, ignore/delete it.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
#!/usr/bin/env bash&lt;br /&gt;
# strfry write-policy plugin — whitelist gate with mtime-cached reload.&lt;br /&gt;
# Protocol: https://github.com/hoytech/strfry/blob/master/docs/plugins.md&lt;br /&gt;
# stdin : one JSON per line  {&amp;quot;type&amp;quot;:&amp;quot;new&amp;quot;,&amp;quot;event&amp;quot;:{&amp;quot;id&amp;quot;:..,&amp;quot;pubkey&amp;quot;:..},...}&lt;br /&gt;
# stdout: one JSON per line  {&amp;quot;id&amp;quot;:&amp;quot;&amp;lt;event id&amp;gt;&amp;quot;,&amp;quot;action&amp;quot;:&amp;quot;accept&amp;quot;|&amp;quot;reject&amp;quot;,&amp;quot;msg&amp;quot;:&amp;quot;..&amp;quot;}&lt;br /&gt;
set -eo pipefail&lt;br /&gt;
&lt;br /&gt;
WL=&amp;quot;${STRFRY_WHITELIST:-/app/policy/whitelist.json}&amp;quot;&lt;br /&gt;
&lt;br /&gt;
cached_mtime=&amp;quot;&amp;quot;&lt;br /&gt;
declare -A allow      # hex pubkey -&amp;gt; 1&lt;br /&gt;
&lt;br /&gt;
reload_if_changed() {&lt;br /&gt;
  local m&lt;br /&gt;
  m=$(stat -c %Y &amp;quot;$WL&amp;quot; 2&amp;gt;/dev/null || echo &amp;quot;&amp;quot;)&lt;br /&gt;
  [[ &amp;quot;$m&amp;quot; == &amp;quot;$cached_mtime&amp;quot; ]] &amp;amp;&amp;amp; return 0&lt;br /&gt;
  cached_mtime=&amp;quot;$m&amp;quot;&lt;br /&gt;
  allow=()&lt;br /&gt;
  if [[ -s &amp;quot;$WL&amp;quot; ]]; then&lt;br /&gt;
    while IFS= read -r hex; do&lt;br /&gt;
      [[ -n &amp;quot;$hex&amp;quot; ]] &amp;amp;&amp;amp; allow[&amp;quot;${hex,,}&amp;quot;]=1&lt;br /&gt;
    done &amp;lt; &amp;lt;(jq -r &#039;.[]? // empty&#039; &amp;quot;$WL&amp;quot; 2&amp;gt;/dev/null || true)&lt;br /&gt;
  fi&lt;br /&gt;
  # Stderr is strfry&#039;s plugin log — handy for &amp;quot;did the reload fire?&amp;quot;&lt;br /&gt;
  echo &amp;quot;[whitelist-policy] reloaded: ${#allow[@]} key(s) from $WL&amp;quot; &amp;gt;&amp;amp;2&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Prime once so the first event isn&#039;t slowed by a cold load.&lt;br /&gt;
reload_if_changed&lt;br /&gt;
&lt;br /&gt;
while IFS= read -r line; do&lt;br /&gt;
  [[ -z &amp;quot;$line&amp;quot; ]] &amp;amp;&amp;amp; continue&lt;br /&gt;
  reload_if_changed&lt;br /&gt;
&lt;br /&gt;
  id=$(echo &amp;quot;$line&amp;quot;     | jq -r &#039;.event.id     // empty&#039;)&lt;br /&gt;
  pubkey=$(echo &amp;quot;$line&amp;quot; | jq -r &#039;.event.pubkey // empty&#039;)&lt;br /&gt;
  pubkey=&amp;quot;${pubkey,,}&amp;quot;&lt;br /&gt;
&lt;br /&gt;
  if [[ -n &amp;quot;$pubkey&amp;quot; &amp;amp;&amp;amp; -n &amp;quot;${allow[$pubkey]:-}&amp;quot; ]]; then&lt;br /&gt;
    printf &#039;{&amp;quot;id&amp;quot;:&amp;quot;%s&amp;quot;,&amp;quot;action&amp;quot;:&amp;quot;accept&amp;quot;}\n&#039; &amp;quot;$id&amp;quot;&lt;br /&gt;
  else&lt;br /&gt;
    printf &#039;{&amp;quot;id&amp;quot;:&amp;quot;%s&amp;quot;,&amp;quot;action&amp;quot;:&amp;quot;reject&amp;quot;,&amp;quot;msg&amp;quot;:&amp;quot;blocked: not on relay whitelist&amp;quot;}\n&#039; &amp;quot;$id&amp;quot;&lt;br /&gt;
  fi&lt;br /&gt;
done&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Make it executable:&lt;br /&gt;
&lt;br /&gt;
 chmod +x /opt/nostr-relay/policy/whitelist-policy.sh&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why bash + jq?&#039;&#039;&#039; Zero extra runtime, and &amp;lt;code&amp;gt;jq&amp;lt;/code&amp;gt; is already an nGate dependency so the mental model stays consistent across the project. For a very high-throughput public relay you would rewrite this in Go/Rust; for an nGated federation-discovery relay the event rate is low and bash is plenty. (If you find the per-event &amp;lt;code&amp;gt;jq&amp;lt;/code&amp;gt; spawn too heavy at scale, that&#039;s the upgrade trigger — note it and move on; don&#039;t pre-optimise.)&lt;br /&gt;
&lt;br /&gt;
== Step 10: Seed the Whitelist ==&lt;br /&gt;
&lt;br /&gt;
Two files:&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;seed.toml&amp;lt;/code&amp;gt;&#039;&#039;&#039; — operator-managed, never auto-removed (same concept and filename as nGate stage 1, [[CLAUDE.md]] decision #6). One hex pubkey per line, &amp;lt;code&amp;gt;#&amp;lt;/code&amp;gt; for comments:&lt;br /&gt;
&lt;br /&gt;
 # /opt/nostr-relay/seed.toml — operator&#039;s always-allowed keys&lt;br /&gt;
 # Put YOUR key here before first start or you lock yourself out.&lt;br /&gt;
 abc123...your64charhexkey...def&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;policy/whitelist.json&amp;lt;/code&amp;gt;&#039;&#039;&#039; — the live file the plugin reads. nGate rewrites this in Step 16. Until then, hand-seed it from your seed keys. A JSON array of hex strings:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
[&lt;br /&gt;
  &amp;quot;abc123...your64charhexkey...def&amp;quot;&lt;br /&gt;
]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
For now they overlap by hand. Once nGate is wired (Step 16) it produces &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt; as &amp;lt;code&amp;gt;(seed.toml ∪ verified+gated discovered keys)&amp;lt;/code&amp;gt; — the seed is always merged in so the operator can never be auto-evicted.&lt;br /&gt;
&lt;br /&gt;
== Step 11: Caddy for TLS ==&lt;br /&gt;
&lt;br /&gt;
Create &amp;lt;code&amp;gt;/opt/nostr-relay/Caddyfile&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
nostr.v4call.com {&lt;br /&gt;
    reverse_proxy nostr-relay:7777&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Caddy auto-obtains and auto-renews a Let&#039;s Encrypt certificate for the domain and reverse-proxies WebSocket traffic to strfry. (This is identical in spirit to the stage-1 nostr-rs-relay Caddyfile — only the upstream port differs: strfry uses 7777, nostr-rs-relay used 8080.)&lt;br /&gt;
&lt;br /&gt;
== Step 12: docker-compose.yml ==&lt;br /&gt;
&lt;br /&gt;
Create &amp;lt;code&amp;gt;/opt/nostr-relay/docker-compose.yml&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
services:&lt;br /&gt;
&lt;br /&gt;
  nostr-relay:&lt;br /&gt;
    image: strfry-ngate:1.0.4          # bash+jq wrapper from Step 7, NOT plain strfry:1.0.4&lt;br /&gt;
    container_name: nostr-relay&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    command: relay&lt;br /&gt;
    volumes:&lt;br /&gt;
      - ./strfry.conf:/etc/strfry.conf:ro&lt;br /&gt;
      - ./strfry-db:/app/strfry-db&lt;br /&gt;
      - ./policy:/app/policy            # plugin + whitelist.json (RW: nGate writes here)&lt;br /&gt;
    expose:&lt;br /&gt;
      - &amp;quot;7777&amp;quot;&lt;br /&gt;
    networks:&lt;br /&gt;
      - relaynet&lt;br /&gt;
&lt;br /&gt;
  caddy:&lt;br /&gt;
    image: caddy:2-alpine&lt;br /&gt;
    container_name: caddy&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    ports:&lt;br /&gt;
      - &amp;quot;80:80&amp;quot;&lt;br /&gt;
      - &amp;quot;443:443&amp;quot;&lt;br /&gt;
    volumes:&lt;br /&gt;
      - ./Caddyfile:/etc/caddy/Caddyfile:ro&lt;br /&gt;
      - ./caddy_data:/data&lt;br /&gt;
      - ./caddy_config:/config&lt;br /&gt;
    depends_on:&lt;br /&gt;
      - nostr-relay&lt;br /&gt;
    networks:&lt;br /&gt;
      - relaynet&lt;br /&gt;
&lt;br /&gt;
networks:&lt;br /&gt;
  relaynet:&lt;br /&gt;
    driver: bridge&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Notes:&lt;br /&gt;
* strfry&#039;s default Dockerfile reads &amp;lt;code&amp;gt;/etc/strfry.conf&amp;lt;/code&amp;gt; and the entrypoint takes a subcommand (&amp;lt;code&amp;gt;relay&amp;lt;/code&amp;gt; to run the relay). If your pinned strfry tag uses a different default config path, check &amp;lt;code&amp;gt;docker run --rm strfry:1.0.4 --help&amp;lt;/code&amp;gt; and adjust the bind-mount target — this is the #1 version-drift gotcha.&lt;br /&gt;
* The &amp;lt;code&amp;gt;./policy&amp;lt;/code&amp;gt; mount is &#039;&#039;&#039;read-write&#039;&#039;&#039; (no &amp;lt;code&amp;gt;:ro&amp;lt;/code&amp;gt;) because nGate rewrites &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt; inside it. The plugin script itself is operator-trusted code.&lt;br /&gt;
* No Docker socket is mounted and there is &#039;&#039;&#039;no restart command&#039;&#039;&#039; anywhere — that whole class of nGate machinery (restart cap, sanity bound, BEGIN/END markers) is simply not needed here.&lt;br /&gt;
&lt;br /&gt;
== Step 13: Start It Up ==&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
 docker compose ps&lt;br /&gt;
 docker compose logs -f caddy        # watch the cert get issued (Ctrl-C to stop tailing)&lt;br /&gt;
 docker compose logs -f nostr-relay  # watch strfry start + the plugin&#039;s reload line&lt;br /&gt;
&lt;br /&gt;
You should see, in the relay log, the plugin&#039;s &amp;lt;code&amp;gt;[whitelist-policy] reloaded: N key(s)&amp;lt;/code&amp;gt; line on the first event (or at prime time). If Caddy logs a certificate error, re-check DNS (Step 1) — it cannot issue a cert until &amp;lt;code&amp;gt;nostr.v4call.com&amp;lt;/code&amp;gt; resolves to this box.&lt;br /&gt;
&lt;br /&gt;
== Step 14: Prove the Whitelist Works ==&lt;br /&gt;
&lt;br /&gt;
Install the &amp;lt;code&amp;gt;nak&amp;lt;/code&amp;gt; CLI on your laptop (or any machine):&lt;br /&gt;
&lt;br /&gt;
 go install github.com/fiatjaf/nak@latest&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Accepted&#039;&#039;&#039; — publish with a key that IS in &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
 nak event -k 1 -c &amp;quot;hello from a whitelisted key&amp;quot; --sec &amp;lt;nsec-of-a-whitelisted-key&amp;gt; wss://nostr.v4call.com&lt;br /&gt;
&lt;br /&gt;
You should get an OK / accepted response.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rejected&#039;&#039;&#039; — publish with a fresh random key that is NOT whitelisted:&lt;br /&gt;
&lt;br /&gt;
 nak event -k 1 -c &amp;quot;i should be blocked&amp;quot; --sec $(nak key generate) wss://nostr.v4call.com&lt;br /&gt;
&lt;br /&gt;
You should get a rejection carrying &amp;lt;code&amp;gt;blocked: not on relay whitelist&amp;lt;/code&amp;gt; (the &amp;lt;code&amp;gt;msg&amp;lt;/code&amp;gt; from the plugin).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Public read still works for everyone&#039;&#039;&#039; (whitelist gates writes only):&lt;br /&gt;
&lt;br /&gt;
 nak req -k 1 wss://nostr.v4call.com&lt;br /&gt;
&lt;br /&gt;
== Step 15: Prove Hot-Reload Works (the whole point) ==&lt;br /&gt;
&lt;br /&gt;
This is the demo that justifies the whole strfry switch.&lt;br /&gt;
&lt;br /&gt;
# Pick a fresh key and confirm it is &#039;&#039;&#039;rejected&#039;&#039;&#039; (it&#039;s not whitelisted yet):&lt;br /&gt;
#: &amp;lt;code&amp;gt;NSEC=$(nak key generate); echo &amp;quot;$NSEC&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
#: &amp;lt;code&amp;gt;nak event -k 1 -c &amp;quot;before&amp;quot; --sec &amp;quot;$NSEC&amp;quot; wss://nostr.v4call.com&amp;lt;/code&amp;gt; → rejected.&lt;br /&gt;
# Get that key&#039;s hex: &amp;lt;code&amp;gt;nak decode &amp;amp;lt;the npub nak printed&amp;amp;gt;&amp;lt;/code&amp;gt; (or it prints hex with &amp;lt;code&amp;gt;nak key public&amp;lt;/code&amp;gt; flows).&lt;br /&gt;
# On the server, add the hex to the whitelist file &#039;&#039;&#039;with no restart&#039;&#039;&#039;:&lt;br /&gt;
#: &amp;lt;code&amp;gt;cd /opt/nostr-relay/policy&amp;lt;/code&amp;gt;&lt;br /&gt;
#: &amp;lt;code&amp;gt;jq &#039;. + [&amp;quot;&amp;amp;lt;newhex&amp;amp;gt;&amp;quot;]&#039; whitelist.json &amp;amp;gt; whitelist.json.tmp &amp;amp;&amp;amp; mv whitelist.json.tmp whitelist.json&amp;lt;/code&amp;gt;&lt;br /&gt;
#: (the atomic &amp;lt;code&amp;gt;tmp&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;mv&amp;lt;/code&amp;gt; is deliberate — never write the live file in place)&lt;br /&gt;
# Immediately re-publish with the same key:&lt;br /&gt;
#: &amp;lt;code&amp;gt;nak event -k 1 -c &amp;quot;after&amp;quot; --sec &amp;quot;$NSEC&amp;quot; wss://nostr.v4call.com&amp;lt;/code&amp;gt; → &#039;&#039;&#039;now accepted&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
No &amp;lt;code&amp;gt;docker restart&amp;lt;/code&amp;gt;, no reconnect blip, open client connections never dropped. That is the entire reason this guide exists. The relay log shows a fresh &amp;lt;code&amp;gt;[whitelist-policy] reloaded: N key(s)&amp;lt;/code&amp;gt; line the moment the file&#039;s mtime changed.&lt;br /&gt;
&lt;br /&gt;
== Step 16: Wire in nGate ==&lt;br /&gt;
&lt;br /&gt;
nGate&#039;s first three phases — &amp;lt;code&amp;gt;ngate-scan.sh&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;ngate-verify.sh&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;ngate-gate.sh&amp;lt;/code&amp;gt; — are &#039;&#039;&#039;unchanged&#039;&#039;&#039; from the stage-3 walkthrough ([[nGate-auto-whitelist]]). They read Hive &amp;lt;code&amp;gt;v4call-server&amp;lt;/code&amp;gt; posts, verify the Hive signature + Nostr attestation, apply the HP/token economic gate, and emit verified+gated NDJSON. Only the final &#039;&#039;&#039;apply&#039;&#039;&#039; phase differs: instead of rewriting &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; and restarting (&amp;lt;code&amp;gt;ngate-apply.sh&amp;lt;/code&amp;gt;), we atomically write &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt; and stop there.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Get nGate onto the box.&#039;&#039;&#039; Clone the repo into the project folder and install its Node crypto-helper dependencies (dhive + nostr-tools — the phase scripts shell out to them for Hive ECDSA / Nostr schnorr verification):&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 git clone https://github.com/completenoobs/ngate.git nGate&lt;br /&gt;
 sudo apt install -y npm&lt;br /&gt;
 cd /opt/nostr-relay/nGate/scripts&lt;br /&gt;
 npm install&lt;br /&gt;
&lt;br /&gt;
(&amp;lt;code&amp;gt;npm install&amp;lt;/code&amp;gt; reads &amp;lt;code&amp;gt;scripts/package.json&amp;lt;/code&amp;gt; and populates &amp;lt;code&amp;gt;scripts/node_modules/&amp;lt;/code&amp;gt;. Skip it and &amp;lt;code&amp;gt;ngate-verify.sh&amp;lt;/code&amp;gt; dies the first time it verifies a signature — &amp;quot;cannot find module &#039;@hiveio/dhive&#039;&amp;quot;.)&lt;br /&gt;
&lt;br /&gt;
The strfry apply backend &amp;lt;code&amp;gt;ngate-strfry-apply.sh&amp;lt;/code&amp;gt; &#039;&#039;&#039;ships in &amp;lt;code&amp;gt;nGate/scripts/&amp;lt;/code&amp;gt;&#039;&#039;&#039; alongside the other phase scripts — you do &#039;&#039;&#039;not&#039;&#039;&#039; need to hand-create it. It is reproduced here for reference so you can see exactly what it does (seed-merge, atomic write, no restart):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
#!/usr/bin/env bash&lt;br /&gt;
# ngate-strfry-apply.sh — Stage-4 apply backend for strfry.&lt;br /&gt;
# Reads verified+gated NDJSON on stdin (same format ngate-apply.sh consumes),&lt;br /&gt;
# merges in seed.toml, atomically rewrites whitelist.json. NO restart needed —&lt;br /&gt;
# strfry&#039;s write-policy plugin re-reads the file live.&lt;br /&gt;
#&lt;br /&gt;
#   ... | ngate-gate.sh | ./ngate-strfry-apply.sh --apply&lt;br /&gt;
#&lt;br /&gt;
# Defaults to --dry-run (nGate convention: write-side scripts need explicit --apply).&lt;br /&gt;
set -eo pipefail&lt;br /&gt;
&lt;br /&gt;
DRY_RUN=true&lt;br /&gt;
[[ &amp;quot;${1:-}&amp;quot; == &amp;quot;--apply&amp;quot; ]] &amp;amp;&amp;amp; DRY_RUN=false&lt;br /&gt;
&lt;br /&gt;
WL=&amp;quot;${NGATE_WHITELIST_PATH:-/opt/nostr-relay/policy/whitelist.json}&amp;quot;&lt;br /&gt;
SEED=&amp;quot;${NGATE_SEED_PATH:-/opt/nostr-relay/seed.toml}&amp;quot;&lt;br /&gt;
&lt;br /&gt;
declare -A keep&lt;br /&gt;
&lt;br /&gt;
# 1. seed.toml — always merged, never auto-removed (CLAUDE.md decision #6)&lt;br /&gt;
if [[ -f &amp;quot;$SEED&amp;quot; ]]; then&lt;br /&gt;
  while IFS= read -r raw; do&lt;br /&gt;
    line=&amp;quot;${raw%%#*}&amp;quot;; line=&amp;quot;$(echo &amp;quot;$line&amp;quot; | tr -d &#039;[:space:]&#039;)&amp;quot;&lt;br /&gt;
    [[ -n &amp;quot;$line&amp;quot; ]] &amp;amp;&amp;amp; keep[&amp;quot;${line,,}&amp;quot;]=1&lt;br /&gt;
  done &amp;lt; &amp;quot;$SEED&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
# 2. verified+gated discovered keys from stdin NDJSON&lt;br /&gt;
while IFS= read -r ev; do&lt;br /&gt;
  [[ -z &amp;quot;$ev&amp;quot; ]] &amp;amp;&amp;amp; continue&lt;br /&gt;
  hex=$(echo &amp;quot;$ev&amp;quot; | jq -r &#039;.nostr_pubkey_hex // empty&#039; 2&amp;gt;/dev/null || true)&lt;br /&gt;
  [[ -n &amp;quot;$hex&amp;quot; ]] &amp;amp;&amp;amp; keep[&amp;quot;${hex,,}&amp;quot;]=1&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
# 3. render sorted JSON array&lt;br /&gt;
new_json=$(printf &#039;%s\n&#039; &amp;quot;${!keep[@]}&amp;quot; | sort | jq -R . | jq -s .)&lt;br /&gt;
&lt;br /&gt;
if [[ &amp;quot;$DRY_RUN&amp;quot; == &amp;quot;true&amp;quot; ]]; then&lt;br /&gt;
  echo &amp;quot;DRY RUN — would write $(echo &amp;quot;$new_json&amp;quot; | jq &#039;length&#039;) key(s) to $WL&amp;quot; &amp;gt;&amp;amp;2&lt;br /&gt;
  echo &amp;quot;$new_json&amp;quot;&lt;br /&gt;
  exit 0&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
# atomic write — strfry&#039;s plugin re-reads on mtime change, no restart&lt;br /&gt;
tmp=&amp;quot;$(mktemp)&amp;quot;&lt;br /&gt;
echo &amp;quot;$new_json&amp;quot; &amp;gt; &amp;quot;$tmp&amp;quot;&lt;br /&gt;
mv &amp;quot;$tmp&amp;quot; &amp;quot;$WL&amp;quot;&lt;br /&gt;
echo &amp;quot;✓ wrote $(echo &amp;quot;$new_json&amp;quot; | jq &#039;length&#039;) key(s) to $WL (no restart)&amp;quot; &amp;gt;&amp;amp;2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;quot;The scripts are already executable from the clone. &#039;&#039;&#039;Run the chain manually first&#039;&#039;&#039; — nGate&#039;s safe default is dry-run, so this changes nothing yet:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay/nGate/scripts&lt;br /&gt;
 ./ngate-scan.sh \&lt;br /&gt;
   | ./ngate-verify.sh \&lt;br /&gt;
   | NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_TOKEN_SYMBOL=CNOOBS NGATE_MIN_TOKEN_AMOUNT=3 ./ngate-gate.sh \&lt;br /&gt;
   | ./ngate-strfry-apply.sh&lt;br /&gt;
&lt;br /&gt;
Read the output. &amp;lt;code&amp;gt;ngate-verify&amp;lt;/code&amp;gt; prints one line per discovered server (&amp;lt;code&amp;gt;OK&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;REJECT&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;skip&amp;lt;/code&amp;gt;); &amp;lt;code&amp;gt;ngate-gate&amp;lt;/code&amp;gt; prints who passed the economic gate; the apply line says &amp;lt;code&amp;gt;DRY RUN — would write N key(s)&amp;lt;/code&amp;gt;. The &amp;lt;code&amp;gt;REJECT … no nostr_attestation&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;skip … no nostr_pubkey_hex&amp;lt;/code&amp;gt; lines for old / pre-Nostr announces are &#039;&#039;&#039;expected&#039;&#039;&#039; and correct ([[CLAUDE.md]] decision #1 — attestation required, no legacy compat), not errors.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;When the dry-run looks right, apply for real&#039;&#039;&#039; (this is the exact command verified on &amp;lt;code&amp;gt;nostr.hive-book.com&amp;lt;/code&amp;gt; — substitute your own gate thresholds):&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay/nGate/scripts&lt;br /&gt;
 ./ngate-scan.sh \&lt;br /&gt;
   | ./ngate-verify.sh \&lt;br /&gt;
   | NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_TOKEN_SYMBOL=CNOOBS NGATE_MIN_TOKEN_AMOUNT=3 ./ngate-gate.sh \&lt;br /&gt;
   | NGATE_MAX_CONSECUTIVE_MISSES=1 ./ngate-strfry-apply.sh --apply&lt;br /&gt;
&lt;br /&gt;
Success looks like &amp;lt;code&amp;gt;✓ wrote N key(s) to /opt/nostr-relay/policy/whitelist.json (no restart)&amp;lt;/code&amp;gt;. Confirm:&lt;br /&gt;
&lt;br /&gt;
 cat /opt/nostr-relay/policy/whitelist.json&lt;br /&gt;
&lt;br /&gt;
It should hold your seed key (from &amp;lt;code&amp;gt;seed.toml&amp;lt;/code&amp;gt;, always merged even though it is not on Hive — that is the point of the seed) &#039;&#039;&#039;plus&#039;&#039;&#039; one hex per verified+gated server. The relay picks it up on the very next event — &#039;&#039;&#039;no restart&#039;&#039;&#039; — and logs &amp;lt;code&amp;gt;[whitelist-policy] reloaded: N key(s)&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;(Note the two flags shown: &amp;lt;code&amp;gt;--apply&amp;lt;/code&amp;gt; turns off dry-run; add &amp;lt;code&amp;gt;--allow-removals&amp;lt;/code&amp;gt; only when you want keys that have dropped off Hive to be pruned. Without &amp;lt;code&amp;gt;--allow-removals&amp;lt;/code&amp;gt; the apply is ADD-only — safe against a transient Hive outage nuking your whitelist. &amp;lt;code&amp;gt;NGATE_MAX_CONSECUTIVE_MISSES&amp;lt;/code&amp;gt; is carried for forward-compat with the deferred miss-counter — see [[#Whats_deferred|What&#039;s deferred]].)&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Step 17: Automate It with cron ==&lt;br /&gt;
&lt;br /&gt;
Manual runs prove it works; cron keeps it current. Every run recomputes the whitelist from Hive and atomically rewrites &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt; — &#039;&#039;&#039;no relay restart, ever&#039;&#039;&#039; — so running it often is cheap and safe.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Recommended: the explicit-env one-liner (this is the tested path).&#039;&#039;&#039; Edit your crontab:&lt;br /&gt;
&lt;br /&gt;
 crontab -e&lt;br /&gt;
&lt;br /&gt;
Add (runs every 30 minutes; adjust the schedule — see the cheatsheet below):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
SHELL=/bin/bash&lt;br /&gt;
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin&lt;br /&gt;
&lt;br /&gt;
*/30 * * * * cd /opt/nostr-relay/nGate/scripts &amp;amp;&amp;amp; flock -n /tmp/ngate.lock ./ngate-scan.sh | ./ngate-verify.sh | NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_TOKEN_SYMBOL=CNOOBS NGATE_MIN_TOKEN_AMOUNT=3 ./ngate-gate.sh | NGATE_MAX_CONSECUTIVE_MISSES=1 ./ngate-strfry-apply.sh --apply --allow-removals &amp;gt;&amp;gt; /opt/nostr-relay/ngate-cron.log 2&amp;gt;&amp;amp;1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Schedule cheatsheet&#039;&#039;&#039; (the five fields are &amp;lt;code&amp;gt;minute hour day-of-month month day-of-week&amp;lt;/code&amp;gt;):&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Cron expression !! Runs&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;*/15 * * * *&amp;lt;/code&amp;gt; || every 15 minutes&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;*/30 * * * *&amp;lt;/code&amp;gt; || every 30 minutes&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;0 * * * *&amp;lt;/code&amp;gt; || every hour, on the hour&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;0 */6 * * *&amp;lt;/code&amp;gt; || every 6 hours&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;0 3 * * *&amp;lt;/code&amp;gt; || once a day at 03:00&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Three cron gotchas that bite everyone&#039;&#039;&#039; (noob-tested, in this guide&#039;s spirit):&lt;br /&gt;
* &#039;&#039;&#039;cron has almost no &amp;lt;code&amp;gt;PATH&amp;lt;/code&amp;gt;.&#039;&#039;&#039; It does &#039;&#039;&#039;not&#039;&#039;&#039; inherit your login shell&#039;s environment, so &amp;lt;code&amp;gt;node&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;jq&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;git&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;flock&amp;lt;/code&amp;gt; may not be found and the run silently does nothing. The &amp;lt;code&amp;gt;PATH=&amp;lt;/code&amp;gt; line at the top of the crontab fixes this; if &amp;lt;code&amp;gt;node&amp;lt;/code&amp;gt; lives somewhere odd (&amp;lt;code&amp;gt;which node&amp;lt;/code&amp;gt; to check), add its directory to that &amp;lt;code&amp;gt;PATH=&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Always redirect to a log&#039;&#039;&#039; (&amp;lt;code&amp;gt;&amp;gt;&amp;gt; ... 2&amp;gt;&amp;amp;1&amp;lt;/code&amp;gt;). Without it cron emails output into a local mailbox you&#039;ll never read, and a broken run is invisible. &amp;lt;code&amp;gt;tail -f /opt/nostr-relay/ngate-cron.log&amp;lt;/code&amp;gt; after the first scheduled fire to confirm it ran clean.&lt;br /&gt;
* &#039;&#039;&#039;&amp;lt;code&amp;gt;flock -n&amp;lt;/code&amp;gt; prevents overlap.&#039;&#039;&#039; A slow Hive scan that runs past the next tick would otherwise start a second copy on top of the first. &amp;lt;code&amp;gt;flock -n /tmp/ngate.lock&amp;lt;/code&amp;gt; makes the new run bail if one is already going. (&amp;lt;code&amp;gt;flock&amp;lt;/code&amp;gt; is from &amp;lt;code&amp;gt;util-linux&amp;lt;/code&amp;gt;, present on Ubuntu by default.)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Alternative: drive the gate config from &amp;lt;code&amp;gt;ngate.yaml&amp;lt;/code&amp;gt; instead of inline env vars.&#039;&#039;&#039; nGate ships &amp;lt;code&amp;gt;ngate.yaml.example&amp;lt;/code&amp;gt; and a loop wrapper &amp;lt;code&amp;gt;ngate-sync.sh&amp;lt;/code&amp;gt; that reads the YAML and translates it into the same &amp;lt;code&amp;gt;NGATE_*&amp;lt;/code&amp;gt; env vars. &#039;&#039;&#039;Caveat — be honest about this:&#039;&#039;&#039; the stock &amp;lt;code&amp;gt;ngate-sync.sh&amp;lt;/code&amp;gt; is hardwired to the stage-1 &amp;lt;code&amp;gt;ngate-apply.sh&amp;lt;/code&amp;gt; (config.toml + restart), &#039;&#039;&#039;not&#039;&#039;&#039; the strfry backend. Until a strfry-aware sync wrapper lands (tracked under [[#Whats_deferred|What&#039;s deferred]]), the clean way to get YAML-driven config on a strfry box is a tiny shim that reads the gate keys out of &amp;lt;code&amp;gt;ngate.yaml&amp;lt;/code&amp;gt; with &amp;lt;code&amp;gt;yq&amp;lt;/code&amp;gt; and feeds the strfry pipeline:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
#!/usr/bin/env bash&lt;br /&gt;
# /opt/nostr-relay/nGate/scripts/ngate-strfry-cron.sh&lt;br /&gt;
# YAML-driven one-shot for the strfry backend. Point cron at THIS instead of&lt;br /&gt;
# the long inline pipeline if you&#039;d rather keep gate config in ngate.yaml.&lt;br /&gt;
set -eo pipefail&lt;br /&gt;
cd &amp;quot;$(dirname &amp;quot;$0&amp;quot;)&amp;quot;&lt;br /&gt;
Y=&amp;quot;${NGATE_YAML:-/opt/nostr-relay/ngate.yaml}&amp;quot;&lt;br /&gt;
&lt;br /&gt;
GA=$(yq -r &#039;.gate.account            // &amp;quot;hive_account&amp;quot;&#039; &amp;quot;$Y&amp;quot;)&lt;br /&gt;
TS=$(yq -r &#039;.gate.min_token_symbol   // &amp;quot;&amp;quot;&#039;             &amp;quot;$Y&amp;quot;)&lt;br /&gt;
TA=$(yq -r &#039;.gate.min_token_amount   // 0&#039;              &amp;quot;$Y&amp;quot;)&lt;br /&gt;
MM=$(yq -r &#039;.max_consecutive_failures // 1&#039;             &amp;quot;$Y&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
./ngate-scan.sh \&lt;br /&gt;
  | ./ngate-verify.sh \&lt;br /&gt;
  | NGATE_GATE_ACCOUNT=&amp;quot;$GA&amp;quot; NGATE_MIN_TOKEN_SYMBOL=&amp;quot;$TS&amp;quot; NGATE_MIN_TOKEN_AMOUNT=&amp;quot;$TA&amp;quot; ./ngate-gate.sh \&lt;br /&gt;
  | NGATE_MAX_CONSECUTIVE_MISSES=&amp;quot;$MM&amp;quot; ./ngate-strfry-apply.sh --apply --allow-removals&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
 chmod +x /opt/nostr-relay/nGate/scripts/ngate-strfry-cron.sh&lt;br /&gt;
&lt;br /&gt;
Then the crontab line collapses to:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
*/30 * * * * flock -n /tmp/ngate.lock /opt/nostr-relay/nGate/scripts/ngate-strfry-cron.sh &amp;gt;&amp;gt; /opt/nostr-relay/ngate-cron.log 2&amp;gt;&amp;amp;1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This shim is &#039;&#039;&#039;not&#039;&#039;&#039; tested end-to-end the way the explicit-env pipeline above is — review it and dry-run it once (drop &amp;lt;code&amp;gt;--apply&amp;lt;/code&amp;gt;) before trusting it on a schedule. The explicit-env crontab line is the path this guide has actually run in production; the YAML shim is offered as the tidier option once you&#039;ve verified it on your box. (&amp;lt;code&amp;gt;yq&amp;lt;/code&amp;gt; here is mikefarah&#039;s Go &amp;lt;code&amp;gt;yq&amp;lt;/code&amp;gt; or kislyuk&#039;s Python &amp;lt;code&amp;gt;yq&amp;lt;/code&amp;gt; — both handle these &amp;lt;code&amp;gt;.field // default&amp;lt;/code&amp;gt; queries; &amp;lt;code&amp;gt;sudo apt install -y yq&amp;lt;/code&amp;gt; or grab the mikefarah release binary.)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Alternative: a compose sidecar instead of cron.&#039;&#039;&#039; If you&#039;d rather not use cron, add an nGate sidecar service to &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt; the same way stage 1 did (a long-running container that loops the pipeline), but its volume mount is just &amp;lt;code&amp;gt;./policy&amp;lt;/code&amp;gt; (read-write, so it can write &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt;) and &amp;lt;code&amp;gt;./seed.toml&amp;lt;/code&amp;gt; — &#039;&#039;&#039;no Docker socket&#039;&#039;&#039;, because nGate no longer needs to restart any container. That removes the single biggest security caveat of the stage-1 sidecar (mounting &amp;lt;code&amp;gt;/var/run/docker.sock&amp;lt;/code&amp;gt; gave the sidecar effective host root).&lt;br /&gt;
&lt;br /&gt;
== Step 18: Optional — Per-User Whitelisting via &amp;lt;code&amp;gt;nostr-announce&amp;lt;/code&amp;gt; ==&lt;br /&gt;
&lt;br /&gt;
Everything up to Step 17 whitelists &#039;&#039;&#039;servers&#039;&#039;&#039; (one Hive post per v4call server, tagged &amp;lt;code&amp;gt;v4call-server&amp;lt;/code&amp;gt;, scanned by &amp;lt;code&amp;gt;ngate-scan.sh&amp;lt;/code&amp;gt;). This optional step adds a &#039;&#039;&#039;second parallel pipeline&#039;&#039;&#039; that whitelists &#039;&#039;&#039;individual users&#039;&#039;&#039; who publish a per-user Nostr-identity binding to Hive (one post per user, tagged &amp;lt;code&amp;gt;nostr-announce&amp;lt;/code&amp;gt;, scanned by &amp;lt;code&amp;gt;ngate-user-scan.sh&amp;lt;/code&amp;gt;). Both pipelines write to the same &amp;lt;code&amp;gt;policy/whitelist.json&amp;lt;/code&amp;gt; — the strfry plugin doesn&#039;t care which pipeline added a key.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Skip this step entirely&#039;&#039;&#039; if you only want server-tier whitelisting. The federation-discovery relay works perfectly without per-user gating; this is a separate use case (e.g. &amp;quot;anyone holding ≥3 CNOOBS can publish to my relay&amp;quot;), not a v4call-fed requirement.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;The user-facing tool&#039;&#039;&#039; that produces these posts ships in the v4call repo: &#039;&#039;&#039;&amp;lt;code&amp;gt;public/nostr-announce.html&amp;lt;/code&amp;gt;&#039;&#039;&#039; ([https://github.com/CompleteNoobs/v4call/blob/main/public/nostr-announce.html source on GitHub], normally served at &amp;lt;code&amp;gt;https://your-v4call-server.com/nostr-announce.html&amp;lt;/code&amp;gt;). A user opens the page, fills in their Hive account, pastes their Nostr &amp;lt;code&amp;gt;nsec&amp;lt;/code&amp;gt; (which never leaves their browser), clicks &#039;&#039;&#039;Sign attestation&#039;&#039;&#039; — the page builds a kind-30078 Nostr event with the Hive account in a tag, signs it with the nsec entirely in-browser, embeds it (base64) in a Hive post titled &amp;lt;code&amp;gt;nostr-announce&amp;lt;/code&amp;gt;, and broadcasts the post via Hive Keychain or a pasted Hive posting key. The nsec is wiped on a successful broadcast. The result is a Hive post whose body contains a &amp;lt;code&amp;gt;[V4CALL-NOSTR-BINDING-V1]&amp;lt;/code&amp;gt; block — that&#039;s what nGate&#039;s user-side pipeline reads.&lt;br /&gt;
&lt;br /&gt;
=== What this pipeline does ===&lt;br /&gt;
&lt;br /&gt;
Per scheduled run (defaults: every 30 min via cron, exactly like the server pipeline):&lt;br /&gt;
# &#039;&#039;&#039;&amp;lt;code&amp;gt;ngate-user-scan.sh&amp;lt;/code&amp;gt;&#039;&#039;&#039; hits Hive RPC for the latest posts tagged &amp;lt;code&amp;gt;nostr-announce&amp;lt;/code&amp;gt;, parses each &amp;lt;code&amp;gt;[V4CALL-NOSTR-BINDING-V1]&amp;lt;/code&amp;gt; block, emits one NDJSON candidate per line. Read-only.&lt;br /&gt;
# &#039;&#039;&#039;&amp;lt;code&amp;gt;ngate-user-verify.sh&amp;lt;/code&amp;gt;&#039;&#039;&#039; base64-decodes each candidate&#039;s attestation, cryptographically verifies the kind-30078 Nostr event (&#039;&#039;&#039;same &amp;lt;code&amp;gt;lib/ngate-verify-nostr-event.mjs&amp;lt;/code&amp;gt; helper used by the server pipeline&#039;&#039;&#039;), and asserts: &amp;lt;code&amp;gt;event.pubkey&amp;lt;/code&amp;gt; matches the declared hex; &amp;lt;code&amp;gt;d&amp;lt;/code&amp;gt;-tag is &amp;lt;code&amp;gt;v4call-nostr-binding&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;v4call_hive_account&amp;lt;/code&amp;gt; tag matches &#039;&#039;&#039;both&#039;&#039;&#039; the post author &#039;&#039;&#039;and&#039;&#039;&#039; the body&#039;s &amp;lt;code&amp;gt;HIVE-ACCOUNT&amp;lt;/code&amp;gt; field. Only passing rows go downstream. Read-only.&lt;br /&gt;
# &#039;&#039;&#039;&amp;lt;code&amp;gt;ngate-gate.sh&amp;lt;/code&amp;gt;&#039;&#039;&#039; (existing, unchanged) reads the rows on stdin, looks up each &amp;lt;code&amp;gt;hive_account&amp;lt;/code&amp;gt;&#039;s HP and/or token balance via Hive RPC + Hive-Engine, and lets through only those that meet the configured thresholds. Read-only.&lt;br /&gt;
# &#039;&#039;&#039;&amp;lt;code&amp;gt;ngate-strfry-apply.sh&amp;lt;/code&amp;gt;&#039;&#039;&#039; (existing, unchanged) merges the surviving hexes with &amp;lt;code&amp;gt;seed.toml&amp;lt;/code&amp;gt;, atomically rewrites &amp;lt;code&amp;gt;policy/whitelist.json&amp;lt;/code&amp;gt;, no restart. The plugin re-reads on mtime change at the very next event.&lt;br /&gt;
&lt;br /&gt;
The two scan/verify scripts mirror the existing &amp;lt;code&amp;gt;ngate-scan.sh&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;ngate-verify.sh&amp;lt;/code&amp;gt; in style, dependencies, NDJSON shape, and exit codes. The downstream &amp;lt;code&amp;gt;ngate-gate.sh&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;ngate-strfry-apply.sh&amp;lt;/code&amp;gt; are completely untouched — &#039;&#039;&#039;zero risk to your existing v4call-server pipeline&#039;&#039;&#039; from adding this step.&lt;br /&gt;
&lt;br /&gt;
=== Verify the scripts exist (after the next nGate pull) ===&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay/nGate&lt;br /&gt;
 git pull&lt;br /&gt;
 ls scripts/ngate-user-scan.sh scripts/ngate-user-verify.sh&lt;br /&gt;
&lt;br /&gt;
If they&#039;re missing, your local nGate clone is behind &amp;lt;code&amp;gt;main&amp;lt;/code&amp;gt;. &amp;lt;code&amp;gt;git pull&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;/opt/nostr-relay/nGate&amp;lt;/code&amp;gt; updates them. No &amp;lt;code&amp;gt;docker&amp;lt;/code&amp;gt; rebuild needed — the nGate scripts run on the host, not inside the relay container.&lt;br /&gt;
&lt;br /&gt;
=== Smoke-test (read-only, no whitelist write) ===&lt;br /&gt;
&lt;br /&gt;
See what&#039;s currently discoverable without touching the whitelist:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay/nGate/scripts&lt;br /&gt;
 ./ngate-user-scan.sh | ./ngate-user-verify.sh | jq -c .&lt;br /&gt;
&lt;br /&gt;
Expected: zero or more NDJSON lines on stdout (each a verified per-user binding) plus chatty stderr from the scan and verify phases (&amp;lt;code&amp;gt;OK @user/permlink — …&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;REJECT …&amp;lt;/code&amp;gt; with a clear reason).&lt;br /&gt;
&lt;br /&gt;
=== Dry-run the full pipeline with a real gate ===&lt;br /&gt;
&lt;br /&gt;
Add the gate you want — example below is &amp;quot;must hold ≥3 CNOOBS on the Hive account that signed the post&amp;quot;:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay/nGate/scripts&lt;br /&gt;
 ./ngate-user-scan.sh | ./ngate-user-verify.sh | \&lt;br /&gt;
   NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_TOKEN_SYMBOL=CNOOBS NGATE_MIN_TOKEN_AMOUNT=3 \&lt;br /&gt;
     ./ngate-gate.sh | \&lt;br /&gt;
   ./ngate-strfry-apply.sh        # dry-run (default; nGate convention)&lt;br /&gt;
&lt;br /&gt;
The last line ends with &amp;lt;code&amp;gt;DRY-RUN: would write N key(s) to /opt/nostr-relay/policy/whitelist.json&amp;lt;/code&amp;gt;. Read the gate log lines on stderr to confirm the right accounts passed/failed before you go live.&lt;br /&gt;
&lt;br /&gt;
=== Schedule it (test relay first) ===&lt;br /&gt;
&lt;br /&gt;
Add to &amp;lt;code&amp;gt;crontab -e&amp;lt;/code&amp;gt;, &#039;&#039;&#039;same box, separate lock file and log&#039;&#039;&#039; so it never blocks the existing v4call-server cron and so diagnostics stay clean:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# nostr-announce per-user pipeline — runs every 30 min, parallel to the v4call-server one&lt;br /&gt;
*/30 * * * * cd /opt/nostr-relay/nGate/scripts &amp;amp;&amp;amp; flock -n /tmp/ngate-user.lock bash -c &#039;./ngate-user-scan.sh | ./ngate-user-verify.sh | NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_TOKEN_SYMBOL=CNOOBS NGATE_MIN_TOKEN_AMOUNT=3 ./ngate-gate.sh | NGATE_MAX_CONSECUTIVE_MISSES=1 ./ngate-strfry-apply.sh --apply --allow-removals&#039; &amp;gt;&amp;gt; /opt/nostr-relay/ngate-user-cron.log 2&amp;gt;&amp;amp;1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Two new things in this line vs the v4call-server cron:&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;&amp;lt;code&amp;gt;/tmp/ngate-user.lock&amp;lt;/code&amp;gt;&#039;&#039;&#039; (not &amp;lt;code&amp;gt;/tmp/ngate.lock&amp;lt;/code&amp;gt;) — separate flock so the user-scan can run alongside the server-scan; one slow Hive node won&#039;t make the other pipeline skip its turn.&lt;br /&gt;
* &#039;&#039;&#039;&amp;lt;code&amp;gt;ngate-user-cron.log&amp;lt;/code&amp;gt;&#039;&#039;&#039; — separate log file makes &amp;quot;did the user gate prune anyone today?&amp;quot; a one-line &amp;lt;code&amp;gt;grep&amp;lt;/code&amp;gt; instead of digging through interleaved server-side noise.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;--allow-removals&amp;lt;/code&amp;gt; tells the apply step it&#039;s OK to remove keys that no longer pass the gate&#039;&#039;&#039; (e.g. a user dropped below 3 CNOOBS). Without it, the whitelist is ADD-only — that&#039;s deliberately the safer default but you almost certainly want removals on for a per-user economic gate. Pair with &amp;lt;code&amp;gt;NGATE_MAX_CONSECUTIVE_MISSES=1&amp;lt;/code&amp;gt; so a single transient Hive-RPC error doesn&#039;t auto-evict — the row has to be genuinely missing on two consecutive runs to drop.&lt;br /&gt;
&lt;br /&gt;
=== Test relay → production rollout discipline ===&lt;br /&gt;
&lt;br /&gt;
Same posture as the v4call-server pipeline: &#039;&#039;&#039;don&#039;t add this cron line to a production relay until the test relay has run it cleanly for at least a few cycles.&#039;&#039;&#039; Watch &amp;lt;code&amp;gt;tail -f /opt/nostr-relay/ngate-user-cron.log&amp;lt;/code&amp;gt;, watch the strfry log for the plugin&#039;s &amp;lt;code&amp;gt;[whitelist-policy] reloaded: N key(s)&amp;lt;/code&amp;gt; lines, and confirm new keys show up in &amp;lt;code&amp;gt;cat /opt/nostr-relay/policy/whitelist.json&amp;lt;/code&amp;gt; only when you expect them to.&lt;br /&gt;
&lt;br /&gt;
=== What this step does NOT do ===&lt;br /&gt;
&lt;br /&gt;
Honest scope:&lt;br /&gt;
* &#039;&#039;&#039;Does not change the strfry policy plugin.&#039;&#039;&#039; The plugin still only checks &amp;quot;is this pubkey in the whitelist?&amp;quot; Per-user and per-server keys are indistinguishable to it. If you want different gating rules per source you&#039;d need a smarter plugin and a second whitelist file — not done here.&lt;br /&gt;
* &#039;&#039;&#039;Does not enforce kind restrictions.&#039;&#039;&#039; A whitelisted user can publish kind 1, kind 30078, kind anything to your relay. Gating happens at identity, not content. If you want a kind allowlist on top, that&#039;s a plugin-level change, not a whitelist-data change.&lt;br /&gt;
* &#039;&#039;&#039;Does not edit any of the existing pipeline scripts.&#039;&#039;&#039; Step 16&#039;s &amp;lt;code&amp;gt;ngate-scan.sh&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;ngate-verify.sh&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;ngate-gate.sh&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;ngate-strfry-apply.sh&amp;lt;/code&amp;gt; are byte-identical to before. This is purely additive.&lt;br /&gt;
* &#039;&#039;&#039;Does not run inside the relay container.&#039;&#039;&#039; The nGate scripts run on the host via cron, write the whitelist file via the bind-mounted &amp;lt;code&amp;gt;./policy&amp;lt;/code&amp;gt; directory. The strfry container only reads — no Docker socket needed, no relay-restart needed, no image rebuild needed.&lt;br /&gt;
&lt;br /&gt;
== Common Problems and Fixes ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;(Several of these were hit and fixed during the first real deploy of this guide to &amp;lt;code&amp;gt;nostr.hive-book.com&amp;lt;/code&amp;gt; — they are reproducible first-boot traps, not edge cases. If you&#039;re following along on a fresh box you will probably meet the first three.)&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
;strfry crash-loops with &amp;lt;code&amp;gt;Unable to set NOFILES limit to 1000000, exceeds max of 524288&amp;lt;/code&amp;gt;&lt;br /&gt;
:strfry tries to raise its open-file limit to 1,000,000 on startup; almost every VPS caps lower, so the &amp;lt;code&amp;gt;setrlimit&amp;lt;/code&amp;gt; fails, you see &amp;lt;code&amp;gt;atexit&amp;lt;/code&amp;gt;, and the container restarts forever. The relay &#039;&#039;&#039;log will say &amp;lt;code&amp;gt;CONFIG: successfully installed&amp;lt;/code&amp;gt; right before the error&#039;&#039;&#039; — that is a red herring; the config parsed, the limit call is what failed. &#039;&#039;&#039;Fix:&#039;&#039;&#039; set &amp;lt;code&amp;gt;nofiles = 0&amp;lt;/code&amp;gt; &#039;&#039;&#039;inside the &amp;lt;code&amp;gt;relay { }&amp;lt;/code&amp;gt; block&#039;&#039;&#039; of &amp;lt;code&amp;gt;strfry.conf&amp;lt;/code&amp;gt; (Step 8 already does this in the current guide). &amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; = &amp;quot;inherit the container&#039;s limit, don&#039;t try to raise it&amp;quot; — host-independent.&lt;br /&gt;
:&#039;&#039;&#039;The subtle part:&#039;&#039;&#039; &amp;lt;code&amp;gt;nofiles&amp;lt;/code&amp;gt; is &#039;&#039;&#039;only&#039;&#039;&#039; read inside &amp;lt;code&amp;gt;relay { }&amp;lt;/code&amp;gt;. If you put it at the &#039;&#039;&#039;top level&#039;&#039;&#039; of &amp;lt;code&amp;gt;strfry.conf&amp;lt;/code&amp;gt; (a natural mistake), strfry&#039;s tolerant parser still prints &amp;lt;code&amp;gt;CONFIG: successfully installed&amp;lt;/code&amp;gt; but &#039;&#039;&#039;silently ignores the key&#039;&#039;&#039; and keeps the built-in 1,000,000 default — so 0, 524288, and anything in between all behave identically (still crashing). If you tried several values and &amp;quot;nothing changed&amp;quot;, this is why: wrong scope, not wrong value. Verify the running container&#039;s view with &amp;lt;code&amp;gt;docker compose run --rm --entrypoint sh nostr-relay -c &#039;grep -n nofiles /etc/strfry.conf&#039;&amp;lt;/code&amp;gt; &#039;&#039;&#039;and&#039;&#039;&#039; confirm the line sits under &amp;lt;code&amp;gt;relay {&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;Edited &amp;lt;code&amp;gt;strfry.conf&amp;lt;/code&amp;gt; but the old behaviour persists / logs show old timestamps&lt;br /&gt;
:strfry reads its config &#039;&#039;&#039;once, at process start&#039;&#039;&#039; — it does not watch the file. A bind-mounted config change is not picked up by a still-running (or merely &amp;lt;code&amp;gt;restart&amp;lt;/code&amp;gt;ed) container, and a crash-looping container may have read the old file before you edited it. &#039;&#039;&#039;Always&#039;&#039;&#039; apply config changes with &amp;lt;code&amp;gt;docker compose up -d --force-recreate nostr-relay&amp;lt;/code&amp;gt;, then read &#039;&#039;&#039;fresh&#039;&#039;&#039; log lines: &amp;lt;code&amp;gt;docker compose logs --since 2m -f nostr-relay&amp;lt;/code&amp;gt;. Check the timestamps are newer than your edit — debugging against stale log output wastes a lot of time. (This is the opposite of the &#039;&#039;&#039;whitelist&#039;&#039;&#039; file, which the plugin re-reads live — see &amp;quot;Whitelist change didn&#039;t take effect&amp;quot; below. Config = static-at-start; whitelist data = live.)&lt;br /&gt;
&lt;br /&gt;
;&amp;lt;code&amp;gt;nak ... status 502&amp;lt;/code&amp;gt; / clients can&#039;t connect, &amp;quot;failed to connect to any of the given relays&amp;quot;&lt;br /&gt;
:A &amp;lt;code&amp;gt;502 Bad Gateway&amp;lt;/code&amp;gt; from your domain means &#039;&#039;&#039;Caddy is up but its upstream (the strfry container) is not listening&#039;&#039;&#039; — almost always because strfry is crash-looping (see the NOFILES entry above). &#039;&#039;&#039;Fix strfry first; the 502 disappears on its own.&#039;&#039;&#039; Don&#039;t debug the 502 as a separate problem until &amp;lt;code&amp;gt;docker compose ps&amp;lt;/code&amp;gt; shows &amp;lt;code&amp;gt;nostr-relay&amp;lt;/code&amp;gt; as a stable &amp;lt;code&amp;gt;Up&amp;lt;/code&amp;gt; (not restarting). Only if strfry is confirmed &amp;lt;code&amp;gt;Up&amp;lt;/code&amp;gt; &#039;&#039;&#039;and&#039;&#039;&#039; you still get 502 is it a real proxy issue — then check the Caddyfile upstream name/port (&amp;lt;code&amp;gt;nostr-relay:7777&amp;lt;/code&amp;gt;) and that both services share the compose network.&lt;br /&gt;
&lt;br /&gt;
;&amp;lt;code&amp;gt;nak event&amp;lt;/code&amp;gt; succeeds to send but the relay replies &amp;lt;code&amp;gt;msg: error: internal error&amp;lt;/code&amp;gt; (reads work, writes fail)&lt;br /&gt;
:The write-policy plugin can&#039;t execute. The official strfry image is &amp;lt;code&amp;gt;FROM alpine&amp;lt;/code&amp;gt; — busybox &amp;lt;code&amp;gt;sh&amp;lt;/code&amp;gt; only, &#039;&#039;&#039;no &amp;lt;code&amp;gt;bash&amp;lt;/code&amp;gt;, no &amp;lt;code&amp;gt;jq&amp;lt;/code&amp;gt;&#039;&#039;&#039;. The Step 9 plugin is bash (associative arrays, &amp;lt;code&amp;gt;${var,,}&amp;lt;/code&amp;gt;, process substitution) and calls &amp;lt;code&amp;gt;jq&amp;lt;/code&amp;gt;, so inside stock strfry the kernel can&#039;t even exec it and strfry returns its generic &amp;lt;code&amp;gt;internal error&amp;lt;/code&amp;gt; for every write. Reads work because the plugin is only invoked on writes. &#039;&#039;&#039;Confirm:&#039;&#039;&#039; &amp;lt;code&amp;gt;docker compose exec nostr-relay sh -lc &#039;which bash; which jq&#039;&amp;lt;/code&amp;gt; — both empty. &#039;&#039;&#039;Fix:&#039;&#039;&#039; build and run the &amp;lt;code&amp;gt;strfry-ngate:1.0.4&amp;lt;/code&amp;gt; wrapper image from Step 7 (&amp;lt;code&amp;gt;apk add --no-cache bash jq&amp;lt;/code&amp;gt;) and set &amp;lt;code&amp;gt;image: strfry-ngate:1.0.4&amp;lt;/code&amp;gt; in compose (Step 12), then &amp;lt;code&amp;gt;docker compose up -d --force-recreate nostr-relay&amp;lt;/code&amp;gt;. Re-test: a whitelisted key now publishes &amp;lt;code&amp;gt;success.&amp;lt;/code&amp;gt;, a non-whitelisted key gets &amp;lt;code&amp;gt;blocked: not on relay whitelist&amp;lt;/code&amp;gt; (the plugin&#039;s own message — proof it&#039;s running).&lt;br /&gt;
&lt;br /&gt;
;&amp;quot;Nothing changed in the whitelist&amp;quot; but the apply log said it wrote keys&lt;br /&gt;
:Almost always a filename mix-up. &amp;lt;code&amp;gt;ngate-strfry-apply.sh&amp;lt;/code&amp;gt; writes &#039;&#039;&#039;&amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt;&#039;&#039;&#039;. The plugin script is &#039;&#039;&#039;&amp;lt;code&amp;gt;whitelist-policy.sh&amp;lt;/code&amp;gt;&#039;&#039;&#039;. There is no &amp;lt;code&amp;gt;whitelist-policy.json&amp;lt;/code&amp;gt; — if you &amp;lt;code&amp;gt;cat&amp;lt;/code&amp;gt; that you&#039;re reading a stray/old file (delete it). Trust the apply script&#039;s own last line: &amp;lt;code&amp;gt;✓ wrote N key(s) to /opt/nostr-relay/policy/whitelist.json&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;cat&amp;lt;/code&amp;gt; &#039;&#039;&#039;that exact path&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
;Doubled log lines in &amp;lt;code&amp;gt;docker compose logs&amp;lt;/code&amp;gt;&lt;br /&gt;
:Usually a stray leftover container from an earlier &amp;lt;code&amp;gt;docker compose run --rm&amp;lt;/code&amp;gt; one-off running alongside the real service. &amp;lt;code&amp;gt;docker compose ps -a&amp;lt;/code&amp;gt;; if you see a &amp;lt;code&amp;gt;...-run-...&amp;lt;/code&amp;gt; container, &amp;lt;code&amp;gt;docker compose down &amp;amp;amp;&amp;amp;amp; docker compose up -d&amp;lt;/code&amp;gt; to clean. If only one container shows and lines are still doubled, it&#039;s just loguru writing to stderr that compose echoes — harmless.&lt;br /&gt;
&lt;br /&gt;
;Caddy can&#039;t get a cert&lt;br /&gt;
:DNS for &amp;lt;code&amp;gt;nostr.v4call.com&amp;lt;/code&amp;gt; isn&#039;t resolving to this box yet. &amp;lt;code&amp;gt;dig +short nostr.v4call.com&amp;lt;/code&amp;gt; must return the VPS IP. Wait, re-check, restart Caddy.&lt;br /&gt;
&lt;br /&gt;
;Every event is rejected, even whitelisted keys&lt;br /&gt;
:(a) &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt; holds &#039;&#039;&#039;npub&#039;&#039;&#039;, not hex — the plugin compares hex. Convert (Step 6). (b) Hex case mismatch — the plugin lowercases both sides, so this shouldn&#039;t bite, but double-check the file has no stray quotes/commas (&amp;lt;code&amp;gt;jq . whitelist.json&amp;lt;/code&amp;gt; must parse). (c) The &amp;lt;code&amp;gt;./policy&amp;lt;/code&amp;gt; mount path inside the container doesn&#039;t match &amp;lt;code&amp;gt;strfry.conf&amp;lt;/code&amp;gt;&#039;s &amp;lt;code&amp;gt;writePolicy.plugin&amp;lt;/code&amp;gt; path.&lt;br /&gt;
&lt;br /&gt;
;strfry won&#039;t start / &amp;quot;config file not found&amp;quot;&lt;br /&gt;
:Your pinned strfry tag expects the config at a different path than &amp;lt;code&amp;gt;/etc/strfry.conf&amp;lt;/code&amp;gt;. Run &amp;lt;code&amp;gt;docker run --rm strfry:1.0.4 --help&amp;lt;/code&amp;gt;, find the expected path, fix the bind-mount target in compose. Pin a known tag; don&#039;t track &amp;lt;code&amp;gt;master&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;Plugin &amp;quot;permission denied&amp;quot; in the relay log&lt;br /&gt;
:&amp;lt;code&amp;gt;chmod +x /opt/nostr-relay/policy/whitelist-policy.sh&amp;lt;/code&amp;gt;. Also confirm it has a valid shebang and Unix line endings (&amp;lt;code&amp;gt;file whitelist-policy.sh&amp;lt;/code&amp;gt; should not say &amp;quot;CRLF&amp;quot;).&lt;br /&gt;
&lt;br /&gt;
;LMDB / database permission errors&lt;br /&gt;
:Same class of issue as the stage-1 nostr-rs-relay SQLite UID gotcha. strfry&#039;s container user must be able to write &amp;lt;code&amp;gt;/app/strfry-db&amp;lt;/code&amp;gt;. Quick fix on a single-purpose box of public events: &amp;lt;code&amp;gt;chmod -R 777 /opt/nostr-relay/strfry-db&amp;lt;/code&amp;gt;. Cleaner fix: &amp;lt;code&amp;gt;chown&amp;lt;/code&amp;gt; to the container&#039;s UID.&lt;br /&gt;
&lt;br /&gt;
;Whitelist change didn&#039;t take effect&lt;br /&gt;
:The plugin reloads on &#039;&#039;&#039;mtime change&#039;&#039;&#039;. If you edited in place with an editor that preserves mtime (rare) or wrote without changing the file, touch it: &amp;lt;code&amp;gt;touch /opt/nostr-relay/policy/whitelist.json&amp;lt;/code&amp;gt;. Always use the atomic &amp;lt;code&amp;gt;tmp&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;mv&amp;lt;/code&amp;gt; pattern (as &amp;lt;code&amp;gt;ngate-strfry-apply.sh&amp;lt;/code&amp;gt; does) — it guarantees a new mtime and never exposes a half-written file to the plugin.&lt;br /&gt;
&lt;br /&gt;
;Build killed (OOM) on a small VPS&lt;br /&gt;
:Add swap (Step 7) and rebuild. The C++ compile is the only memory-hungry part; once the image exists, runtime RAM is small.&lt;br /&gt;
&lt;br /&gt;
;&amp;lt;code&amp;gt;ngate-user-scan.sh&amp;lt;/code&amp;gt; finds posts but &amp;lt;code&amp;gt;ngate-user-verify.sh&amp;lt;/code&amp;gt; rejects &#039;&#039;&#039;every one&#039;&#039;&#039; with &amp;quot;attestation v4call_hive_account tag ≠ post author&amp;quot;&lt;br /&gt;
:The post was authored by one Hive account but its body&#039;s attestation tags name a different Hive account. Usually means somebody copy-pasted the body of someone else&#039;s &amp;lt;code&amp;gt;nostr-announce&amp;lt;/code&amp;gt; post into their own draft and re-posted under their own account — the Nostr signature is still valid (the original signer&#039;s nsec produced it) but the tag no longer matches the new author, so the bind is broken on purpose. This is &#039;&#039;&#039;the system working&#039;&#039;&#039; — a real binding requires the original signer to publish under &#039;&#039;&#039;their own&#039;&#039;&#039; Hive account. Tell the user to re-sign in &amp;lt;code&amp;gt;nostr-announce.html&amp;lt;/code&amp;gt; using their own Hive account + their own nsec, then re-post.&lt;br /&gt;
&lt;br /&gt;
;&amp;lt;code&amp;gt;ngate-user-verify.sh&amp;lt;/code&amp;gt; rejects a post with &amp;quot;attestation v4call_hive_account tag ≠ body HIVE-ACCOUNT field&amp;quot;&lt;br /&gt;
:The post body&#039;s &amp;lt;code&amp;gt;HIVE-ACCOUNT:&amp;lt;/code&amp;gt; line says &amp;lt;code&amp;gt;alice&amp;lt;/code&amp;gt; but the signed attestation inside it names &amp;lt;code&amp;gt;bob&amp;lt;/code&amp;gt;. Same class as above — usually a copy-paste mistake or an attempt to fool a casual reader who only skims the body. The verify rejects on the cryptographic tag, not the human-readable body line, so this never reaches the whitelist. Re-sign in &amp;lt;code&amp;gt;nostr-announce.html&amp;lt;/code&amp;gt; with the correct Hive account in the form field, the page generates a fresh attestation, post again.&lt;br /&gt;
&lt;br /&gt;
;&amp;lt;code&amp;gt;ngate-user-verify.sh&amp;lt;/code&amp;gt; says &amp;quot;missing nostr_pubkey_hex or nostr_attestation_b64&amp;quot;&lt;br /&gt;
:The post body is missing one of the required fields in the &amp;lt;code&amp;gt;[V4CALL-NOSTR-BINDING-V1]&amp;lt;/code&amp;gt; block. Almost always because the user posted manually via peakd.com / hive.blog instead of using &amp;lt;code&amp;gt;nostr-announce.html&amp;lt;/code&amp;gt; and forgot a line. Easiest fix: use the tool — it builds the block correctly every time.&lt;br /&gt;
&lt;br /&gt;
;User gets &amp;lt;code&amp;gt;Post failed: unknown error&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;nostr-announce.html&amp;lt;/code&amp;gt;&lt;br /&gt;
:Almost never a v4call/nGate bug. Two Hive-side causes: (a) Hive enforces a &#039;&#039;&#039;~5 minute gap between posts from the same account&#039;&#039;&#039; — if the user posted anything (this binding, a regular post, anything) less than 5 min ago, the broadcast fails. Wait, retry. (b) &#039;&#039;&#039;Low Resource Credits&#039;&#039;&#039;, especially on fresh accounts with no staked Hive Power — check at &amp;lt;code&amp;gt;https://hivestats.io/@username&amp;lt;/code&amp;gt;. Browser DevTools Console shows the full Hive response which usually carries the actual reason.&lt;br /&gt;
&lt;br /&gt;
== What&#039;s deferred ==&lt;br /&gt;
&lt;br /&gt;
Honest scope notes, so nobody is surprised later:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;3-strike miss tolerance + state.json&#039;&#039;&#039; — &amp;lt;code&amp;gt;ngate-apply.sh&amp;lt;/code&amp;gt; (stage 1) tolerates a key being briefly missing from Hive for N cycles before removing it ([[CLAUDE.md]] decision #4), tracked in &amp;lt;code&amp;gt;state.json&amp;lt;/code&amp;gt;. The minimal &amp;lt;code&amp;gt;ngate-strfry-apply.sh&amp;lt;/code&amp;gt; above is &#039;&#039;&#039;recompute-fresh each run&#039;&#039;&#039; (seed ∪ current verified+gated set). That is safe and matches nGate&#039;s &amp;quot;Hive is source of truth, ADD-only on partial failure&amp;quot; philosophy &#039;&#039;&#039;only if&#039;&#039;&#039; you keep nGate&#039;s existing ADD-only guard in &amp;lt;code&amp;gt;ngate-sync.sh&amp;lt;/code&amp;gt; (don&#039;t pass &amp;lt;code&amp;gt;--allow-removals&amp;lt;/code&amp;gt; on a partial upstream failure). Porting the full miss-counter/state machinery to the strfry backend is a follow-up, not done here.&lt;br /&gt;
* &#039;&#039;&#039;Restart cap / sanity bound&#039;&#039;&#039; — intentionally &#039;&#039;&#039;dropped&#039;&#039;&#039;, not ported. There is no restart, so a restart loop is impossible by construction. The sanity bound (refuse to empty the whitelist on garbage upstream) is still worth porting later as a guard against a bad scan nuking the file; for now the seed-merge means the operator&#039;s own key always survives.&lt;br /&gt;
* &#039;&#039;&#039;High-throughput plugin&#039;&#039;&#039; — bash+jq per event is fine for a low-rate federation-discovery relay. If this relay ever becomes a busy public one, rewrite the plugin in Go/Rust. Note it; don&#039;t pre-optimise.&lt;br /&gt;
&lt;br /&gt;
These are tracked against Stage 4 in [[STATUS.md]].&lt;br /&gt;
&lt;br /&gt;
== Tips for Noobs ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;The plugin is just a program that answers yes/no per event.&#039;&#039;&#039; If you can write a shell script, you can write a strfry policy. Test it by hand: &amp;lt;code&amp;gt;echo &#039;{&amp;quot;type&amp;quot;:&amp;quot;new&amp;quot;,&amp;quot;event&amp;quot;:{&amp;quot;id&amp;quot;:&amp;quot;x&amp;quot;,&amp;quot;pubkey&amp;quot;:&amp;quot;abc&amp;quot;}}&#039; | ./policy/whitelist-policy.sh&amp;lt;/code&amp;gt; should print an accept/reject line.&lt;br /&gt;
* &#039;&#039;&#039;Atomic writes always.&#039;&#039;&#039; Any time something rewrites &amp;lt;code&amp;gt;whitelist.json&amp;lt;/code&amp;gt; — nGate, you by hand, a cron — write to a temp file then &amp;lt;code&amp;gt;mv&amp;lt;/code&amp;gt;. &amp;lt;code&amp;gt;mv&amp;lt;/code&amp;gt; on the same filesystem is atomic; the plugin never sees a half-file, and mtime always bumps so the reload fires.&lt;br /&gt;
* &#039;&#039;&#039;seed.toml is your seatbelt.&#039;&#039;&#039; Put your own key there before first start. Even if every scan fails forever, you can still publish.&lt;br /&gt;
* &#039;&#039;&#039;Read is public, write is gated.&#039;&#039;&#039; That&#039;s the model. Anyone can read your relay&#039;s events; only whitelisted keys can write. If you want private reads too, that&#039;s a different (and more complex) NIP-42 auth setup — out of scope here.&lt;br /&gt;
* &#039;&#039;&#039;strfry ≠ nostr-rs-relay config.&#039;&#039;&#039; Don&#039;t copy a &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; across. Different daemon, different config language. The only thing that carries over conceptually is &amp;quot;a list of allowed hex pubkeys&amp;quot; — and now that list is a live file, not a restart-on-change config block.&lt;br /&gt;
* &#039;&#039;&#039;Keep the strfry tag pinned.&#039;&#039;&#039; &amp;lt;code&amp;gt;strfry:1.0.4&amp;lt;/code&amp;gt;, not &amp;lt;code&amp;gt;strfry:latest&amp;lt;/code&amp;gt;. When you choose to upgrade, do it deliberately: bump the tag, &amp;lt;code&amp;gt;git checkout&amp;lt;/code&amp;gt; the new release, rebuild, test Step 14 + 15 again.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&#039;&#039;Part of the v4call / nGate documentation set. See also: [[Nostr Relay With a 3-Key Whitelist]] (stage 1, nostr-rs-relay), [[nGate-auto-whitelist]] (stage 3, the scan→verify→gate→apply pipeline), [[V4call]] (the platform this federation-discovery layer serves).&#039;&#039;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Ngate_basic&amp;diff=759</id>
		<title>Ngate basic</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Ngate_basic&amp;diff=759"/>
		<updated>2026-05-26T00:38:07Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;{{:LICENCE_HEADER_MIT}}   = nGate — Deploy to a Fresh Ubuntu 24.04 Server =  From CompleteNoobs  This guide walks an operator through deploying nGate on top of an existing stage-1 nostr-rs-relay running on a Vultr Ubuntu 24.04 box. After this you&amp;#039;ll have:  * The relay still running as before. * Caddy still terminating TLS as before. * &amp;#039;&amp;#039;&amp;#039;A new sidecar container&amp;#039;&amp;#039;&amp;#039; (&amp;lt;code&amp;gt;ngate-sync&amp;lt;/code&amp;gt;) running alongside,   scanning Hive every...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_MIT}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= nGate — Deploy to a Fresh Ubuntu 24.04 Server =&lt;br /&gt;
&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
&lt;br /&gt;
This guide walks an operator through deploying nGate on top of an existing&lt;br /&gt;
[[Nostr_Relay_With_a_3-Key_Whitelist|stage-1 nostr-rs-relay]] running on a&lt;br /&gt;
Vultr Ubuntu 24.04 box. After this you&#039;ll have:&lt;br /&gt;
&lt;br /&gt;
* The relay still running as before.&lt;br /&gt;
* Caddy still terminating TLS as before.&lt;br /&gt;
* &#039;&#039;&#039;A new sidecar container&#039;&#039;&#039; (&amp;lt;code&amp;gt;ngate-sync&amp;lt;/code&amp;gt;) running alongside,&lt;br /&gt;
  scanning Hive every 6 hours, updating the relay&#039;s &amp;lt;code&amp;gt;pubkey_whitelist&amp;lt;/code&amp;gt;&lt;br /&gt;
  automatically when v4call-server operators announce themselves.&lt;br /&gt;
* &#039;&#039;&#039;Cryptographic enforcement&#039;&#039;&#039;: only posts that prove ownership of both&lt;br /&gt;
  the Hive account AND the Nostr key (via mutual attestation) get whitelisted.&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
&lt;br /&gt;
You&#039;ve already:&lt;br /&gt;
&lt;br /&gt;
* Followed [[Nostr_Relay_With_a_3-Key_Whitelist|stage 1]] and have a working&lt;br /&gt;
  &amp;lt;code&amp;gt;wss://nostr.YOUR-DOMAIN/&amp;lt;/code&amp;gt; relay.&lt;br /&gt;
* Followed [[Nostr_Hands-On|stage 2]] enough to be comfortable with the&lt;br /&gt;
  Nostr protocol fundamentals.&lt;br /&gt;
* Have at least one v4call-server announce post on Hive (signed via&lt;br /&gt;
  &amp;lt;code&amp;gt;server-sign.html&amp;lt;/code&amp;gt;, announced via &amp;lt;code&amp;gt;server-announce.html&amp;lt;/code&amp;gt;)&lt;br /&gt;
  to use as test input.&lt;br /&gt;
&lt;br /&gt;
If you don&#039;t have a v4call-server post yet, you can still deploy nGate and&lt;br /&gt;
test it (the scan will find existing operators&#039; posts), you just won&#039;t be in&lt;br /&gt;
the whitelist until you publish your own.&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
&lt;br /&gt;
* [[#Step_1:_SSH_to_the_relay_box|1 Step 1: SSH to the relay box]]&lt;br /&gt;
* [[#Step_2:_Install_runtime_dependencies|2 Step 2: Install runtime dependencies]]&lt;br /&gt;
* [[#Step_3:_Clone_nGate|3 Step 3: Clone nGate]]&lt;br /&gt;
* [[#Step_4:_Install_npm_dependencies|4 Step 4: Install npm dependencies]]&lt;br /&gt;
* [[#Step_5:_Bootstrap_config.toml|5 Step 5: Bootstrap config.toml]]&lt;br /&gt;
* [[#Step_6:_Create_seed.toml|6 Step 6: Create seed.toml (CRITICAL — don&#039;t skip)]]&lt;br /&gt;
* [[#Step_7:_Configure_ngate.yaml|7 Step 7: Configure ngate.yaml]]&lt;br /&gt;
* [[#Step_8:_Dry-run_the_chain|8 Step 8: Dry-run the chain]]&lt;br /&gt;
* [[#Step_9:_Merge_sidecar_into_docker-compose.yml|9 Step 9: Merge sidecar into docker-compose.yml]]&lt;br /&gt;
* [[#Step_10:_Build_and_start_the_sidecar|10 Step 10: Build and start the sidecar]]&lt;br /&gt;
* [[#Step_11:_Watch_a_cycle|11 Step 11: Watch a cycle]]&lt;br /&gt;
* [[#Step_12:_Routine_monitoring|12 Step 12: Routine monitoring]]&lt;br /&gt;
* [[#Updating_nGate|13 Updating nGate]]&lt;br /&gt;
* [[#Known_gotchas|14 Known gotchas]]&lt;br /&gt;
* [[#Removing_nGate|15 Removing nGate (back to manual whitelist)]]&lt;br /&gt;
&lt;br /&gt;
== Step 1: SSH to the relay box ==&lt;br /&gt;
&lt;br /&gt;
 ssh root@nostr.YOUR-DOMAIN&lt;br /&gt;
&lt;br /&gt;
If you set up a non-root user during stage 1, use that and prefix everything&lt;br /&gt;
with &amp;lt;code&amp;gt;sudo&amp;lt;/code&amp;gt; as needed.&lt;br /&gt;
&lt;br /&gt;
== Step 2: Install runtime dependencies ==&lt;br /&gt;
&lt;br /&gt;
nGate needs a few things beyond what stage 1 installed. Inside the sidecar&lt;br /&gt;
container, all deps are auto-installed (Alpine image bakes Node + jq + curl&lt;br /&gt;
+ docker-cli + yq). Outside the container — for dry-runs and manual sync&lt;br /&gt;
commands — install on the host:&lt;br /&gt;
&lt;br /&gt;
 apt update&lt;br /&gt;
 apt install -y nodejs npm jq curl&lt;br /&gt;
&lt;br /&gt;
Verify:&lt;br /&gt;
&lt;br /&gt;
 for c in bash curl jq sed awk node; do command -v $c &amp;gt;/dev/null &amp;amp;&amp;amp; echo &amp;quot;✓ $c&amp;quot; || echo &amp;quot;✗ $c MISSING&amp;quot;; done&lt;br /&gt;
&lt;br /&gt;
All six should print ✓.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Clone nGate ==&lt;br /&gt;
&lt;br /&gt;
The relay lives at &amp;lt;code&amp;gt;/opt/nostr-relay/&amp;lt;/code&amp;gt;. Clone nGate next to it so&lt;br /&gt;
the bind-mount paths in &amp;lt;code&amp;gt;docker-compose.example.yml&amp;lt;/code&amp;gt; work:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 git clone https://github.com/CompleteNoobs/nGate src&lt;br /&gt;
&lt;br /&gt;
That gives you:&lt;br /&gt;
&lt;br /&gt;
 /opt/nostr-relay/&lt;br /&gt;
 ├── config.toml             ← from stage 1&lt;br /&gt;
 ├── Caddyfile               ← from stage 1&lt;br /&gt;
 ├── docker-compose.yml      ← from stage 1&lt;br /&gt;
 ├── data/                   ← from stage 1 (relay sqlite DB)&lt;br /&gt;
 ├── caddy_data/             ← from stage 1&lt;br /&gt;
 └── src/                    ← NEW (nGate repo)&lt;br /&gt;
     ├── scripts/&lt;br /&gt;
     ├── ngate.yaml.example&lt;br /&gt;
     ├── docker-compose.example.yml&lt;br /&gt;
     └── ...&lt;br /&gt;
&lt;br /&gt;
For convenience, symlink the scripts dir + copy the YAML template up to&lt;br /&gt;
&amp;lt;code&amp;gt;/opt/nostr-relay/&amp;lt;/code&amp;gt; (so bind-mount paths are simple):&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 ln -s src/scripts scripts&lt;br /&gt;
 cp src/ngate.yaml.example ngate.yaml&lt;br /&gt;
&lt;br /&gt;
Now &amp;lt;code&amp;gt;scripts/ngate-*.sh&amp;lt;/code&amp;gt; work from &amp;lt;code&amp;gt;/opt/nostr-relay/&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Tip&#039;&#039;&#039;: you can also clone nGate anywhere else and adjust the&lt;br /&gt;
&amp;lt;code&amp;gt;NGATE_*&amp;lt;/code&amp;gt; env-vars in &amp;lt;code&amp;gt;ngate.yaml&amp;lt;/code&amp;gt; to point at the&lt;br /&gt;
right paths. The clone-into-&amp;lt;code&amp;gt;src/&amp;lt;/code&amp;gt;-and-symlink approach is just&lt;br /&gt;
the path of least resistance.&lt;br /&gt;
&lt;br /&gt;
== Step 4: Install npm dependencies ==&lt;br /&gt;
&lt;br /&gt;
The Hive ECDSA + Nostr schnorr helpers are Node scripts that need&lt;br /&gt;
&amp;lt;code&amp;gt;@hiveio/dhive&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;nostr-tools&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay/src/scripts&lt;br /&gt;
 npm install&lt;br /&gt;
&lt;br /&gt;
This creates &amp;lt;code&amp;gt;node_modules/&amp;lt;/code&amp;gt; in the scripts folder (~35MB).&lt;br /&gt;
Gitignored — won&#039;t bloat the repo. One-time cost; subsequent&lt;br /&gt;
&amp;lt;code&amp;gt;git pull&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;npm install&amp;lt;/code&amp;gt; is incremental.&lt;br /&gt;
&lt;br /&gt;
Verify both deps are present:&lt;br /&gt;
&lt;br /&gt;
 ls node_modules/@hiveio/dhive/package.json&lt;br /&gt;
 ls node_modules/nostr-tools/package.json&lt;br /&gt;
&lt;br /&gt;
Both should exist.&lt;br /&gt;
&lt;br /&gt;
== Step 5: Bootstrap config.toml ==&lt;br /&gt;
&lt;br /&gt;
Your stage-1 &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; has an &amp;lt;code&amp;gt;[authorization]&amp;lt;/code&amp;gt;&lt;br /&gt;
section with hand-pasted hex pubkeys. nGate manages a specific block of&lt;br /&gt;
this file between BEGIN/END marker comments. Run the bootstrap to wrap your&lt;br /&gt;
existing section:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 ./scripts/ngate-apply.sh --bootstrap&lt;br /&gt;
&lt;br /&gt;
Output should look like:&lt;br /&gt;
&lt;br /&gt;
 ngate-apply: ✓ Bootstrapped — wrapped [authorization] in BEGIN/END markers.&lt;br /&gt;
 ngate-apply:   ngate-apply now manages everything between those markers.&lt;br /&gt;
 ngate-apply:   Operator-edited keys go in: /opt/nostr-relay/seed.toml&lt;br /&gt;
&lt;br /&gt;
Inspect &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
 cat /opt/nostr-relay/config.toml&lt;br /&gt;
&lt;br /&gt;
You should see your existing pubkey_whitelist entries, now wrapped:&lt;br /&gt;
&lt;br /&gt;
 # === BEGIN NGATE-MANAGED — DO NOT EDIT BY HAND ===&lt;br /&gt;
 [authorization]&lt;br /&gt;
 pubkey_whitelist = [&lt;br /&gt;
   &amp;quot;your_existing_hex_1&amp;quot;,&lt;br /&gt;
   &amp;quot;your_existing_hex_2&amp;quot;,&lt;br /&gt;
   &amp;quot;your_existing_hex_3&amp;quot;,&lt;br /&gt;
 ]&lt;br /&gt;
 # === END NGATE-MANAGED ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;⚠ From this point forward, don&#039;t hand-edit between those markers.&#039;&#039;&#039;&lt;br /&gt;
nGate will overwrite that region on every sync. Manual additions go in&lt;br /&gt;
&amp;lt;code&amp;gt;seed.toml&amp;lt;/code&amp;gt; (next step).&lt;br /&gt;
&lt;br /&gt;
== Step 6: Create seed.toml (CRITICAL — don&#039;t skip) ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why this matters&#039;&#039;&#039;: nGate&#039;s first &amp;lt;code&amp;gt;--apply&amp;lt;/code&amp;gt; with&lt;br /&gt;
&amp;lt;code&amp;gt;--allow-removals&amp;lt;/code&amp;gt; can delete entries that aren&#039;t currently&lt;br /&gt;
backed by a valid v4call-server post on Hive — including yours, if your&lt;br /&gt;
operator key isn&#039;t yet announced. Without a seed entry, you can lock&lt;br /&gt;
yourself out.&lt;br /&gt;
&lt;br /&gt;
Make a &amp;lt;code&amp;gt;seed.toml&amp;lt;/code&amp;gt; at &amp;lt;code&amp;gt;/opt/nostr-relay/seed.toml&amp;lt;/code&amp;gt;&lt;br /&gt;
listing every hex pubkey that should ALWAYS be allowed, regardless of&lt;br /&gt;
discovery state. Most importantly: your own operator key.&lt;br /&gt;
&lt;br /&gt;
 nano /opt/nostr-relay/seed.toml&lt;br /&gt;
&lt;br /&gt;
Paste:&lt;br /&gt;
&lt;br /&gt;
 # nGate seed list — operator-managed, never auto-removed.&lt;br /&gt;
 # One hex pubkey per line. Comments allowed.&lt;br /&gt;
 #&lt;br /&gt;
 # Critical: include your own operator key here so nGate can&#039;t accidentally&lt;br /&gt;
 # lock you out if your v4call-server post temporarily fails verification.&lt;br /&gt;
&lt;br /&gt;
 your_operator_hex_pubkey_here   # @noblemage&#039;s nostr key&lt;br /&gt;
&lt;br /&gt;
If you&#039;ve been running stage 1 with 3 manual keys, you might want all 3 as&lt;br /&gt;
seeds initially. Worst case is one extra entry in the whitelist; not a&lt;br /&gt;
correctness problem.&lt;br /&gt;
&lt;br /&gt;
== Step 7: Configure ngate.yaml ==&lt;br /&gt;
&lt;br /&gt;
Open the YAML config and edit for your relay:&lt;br /&gt;
&lt;br /&gt;
 nano /opt/nostr-relay/ngate.yaml&lt;br /&gt;
&lt;br /&gt;
The defaults are sensible. Key fields to customise:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;instance_name&amp;lt;/code&amp;gt; — appears in logs. e.g. &amp;lt;code&amp;gt;nostr-hive-book&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;scan_interval_seconds&amp;lt;/code&amp;gt; — default 21600 (6 hours). If you&lt;br /&gt;
  want faster updates and don&#039;t mind the Hive RC, drop to 3600 (1 hour).&lt;br /&gt;
* &amp;lt;code&amp;gt;gate.account&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;escrow&amp;lt;/code&amp;gt; (default; gates the&lt;br /&gt;
  operational account that holds caller funds) or &amp;lt;code&amp;gt;hive_account&amp;lt;/code&amp;gt;&lt;br /&gt;
  (gates the announce-signing account).&lt;br /&gt;
* &amp;lt;code&amp;gt;gate.min_hp&amp;lt;/code&amp;gt; — minimum HP staked. &amp;lt;code&amp;gt;3&amp;lt;/code&amp;gt; is a fine&lt;br /&gt;
  test threshold. Production servers might want 30+.&lt;br /&gt;
* &amp;lt;code&amp;gt;gate.min_token_*&amp;lt;/code&amp;gt; — leave blank to disable; set to a&lt;br /&gt;
  Hive-Engine token symbol + amount if you want token-gating.&lt;br /&gt;
* &amp;lt;code&amp;gt;max_consecutive_failures&amp;lt;/code&amp;gt; — 3 = ~18 hours of absence before&lt;br /&gt;
  remove. Keep default unless you have a reason.&lt;br /&gt;
* &amp;lt;code&amp;gt;restart_command&amp;lt;/code&amp;gt; — default &amp;lt;code&amp;gt;docker restart nostr-relay&amp;lt;/code&amp;gt;&lt;br /&gt;
  matches the container_name in your docker-compose.yml. Don&#039;t change&lt;br /&gt;
  unless you renamed the relay container.&lt;br /&gt;
* &amp;lt;code&amp;gt;paths.*&amp;lt;/code&amp;gt; — only change if you put nGate somewhere other than&lt;br /&gt;
  &amp;lt;code&amp;gt;/opt/nostr-relay/&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Step 8: Dry-run the chain ==&lt;br /&gt;
&lt;br /&gt;
Before turning the sidecar loose, run the full pipeline manually in&lt;br /&gt;
&amp;lt;code&amp;gt;--dry-run&amp;lt;/code&amp;gt; mode (no writes, no restarts):&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 ./scripts/ngate-scan.sh 2&amp;gt;/dev/null \&lt;br /&gt;
   | ./scripts/ngate-verify.sh 2&amp;gt;&amp;amp;1 \&lt;br /&gt;
   | NGATE_MIN_HP=3 ./scripts/ngate-gate.sh 2&amp;gt;&amp;amp;1 \&lt;br /&gt;
   | ./scripts/ngate-apply.sh&lt;br /&gt;
&lt;br /&gt;
Expected stderr output sequence:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;ngate-scan:&amp;lt;/code&amp;gt; ok, N posts&lt;br /&gt;
* &amp;lt;code&amp;gt;ngate-verify:&amp;lt;/code&amp;gt; OK / REJECT / skip for each candidate&lt;br /&gt;
* &amp;lt;code&amp;gt;ngate-gate:&amp;lt;/code&amp;gt; OK / REJECT for each verified candidate&lt;br /&gt;
* &amp;lt;code&amp;gt;ngate-apply:&amp;lt;/code&amp;gt; seed: X loaded, current: Y in whitelist, stdin:&lt;br /&gt;
  Z passed, summary: add=… keep=… tolerate=… remove=… → DRY RUN, no&lt;br /&gt;
  files changed.&lt;br /&gt;
&lt;br /&gt;
Read the output. Check that:&lt;br /&gt;
&lt;br /&gt;
* Your own operator key is in the SEED count.&lt;br /&gt;
* Your v4call-server post (if you have one) appears as OK in verify and&lt;br /&gt;
  passes the gate (or fails for an understandable reason).&lt;br /&gt;
* The summary &amp;quot;new whitelist size&amp;quot; looks plausible.&lt;br /&gt;
* config.toml hasn&#039;t been modified (&amp;lt;code&amp;gt;cat config.toml&amp;lt;/code&amp;gt; shows the&lt;br /&gt;
  same content as after bootstrap).&lt;br /&gt;
&lt;br /&gt;
If anything looks off, fix it (re-sign + re-announce, adjust ngate.yaml,&lt;br /&gt;
adjust seed.toml) before going live.&lt;br /&gt;
&lt;br /&gt;
== Step 9: Merge sidecar into docker-compose.yml ==&lt;br /&gt;
&lt;br /&gt;
Your stage-1 &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt; already has the &amp;lt;code&amp;gt;nostr-relay&amp;lt;/code&amp;gt;&lt;br /&gt;
and &amp;lt;code&amp;gt;caddy&amp;lt;/code&amp;gt; services. The nGate sidecar adds a third. Open both&lt;br /&gt;
files:&lt;br /&gt;
&lt;br /&gt;
 nano /opt/nostr-relay/docker-compose.yml&lt;br /&gt;
 # In another terminal:&lt;br /&gt;
 cat /opt/nostr-relay/src/docker-compose.example.yml&lt;br /&gt;
&lt;br /&gt;
Copy the &amp;lt;code&amp;gt;ngate-sync&amp;lt;/code&amp;gt; service block from the example into your&lt;br /&gt;
real &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt;. The block looks like:&lt;br /&gt;
&lt;br /&gt;
 services:&lt;br /&gt;
   # ... your existing nostr-relay + caddy services ...&lt;br /&gt;
&lt;br /&gt;
   ngate-sync:&lt;br /&gt;
     build:&lt;br /&gt;
       context: .&lt;br /&gt;
       dockerfile: src/scripts/Dockerfile&lt;br /&gt;
     container_name: ngate-sync&lt;br /&gt;
     restart: unless-stopped&lt;br /&gt;
     volumes:&lt;br /&gt;
       - ./src/scripts:/app/scripts&lt;br /&gt;
       - ./ngate.yaml:/app/ngate.yaml:ro&lt;br /&gt;
       - ./seed.toml:/app/seed.toml:ro&lt;br /&gt;
       - ./config.toml:/app/config.toml&lt;br /&gt;
       - ./state.json:/app/state.json&lt;br /&gt;
       - /var/run/docker.sock:/var/run/docker.sock&lt;br /&gt;
     environment:&lt;br /&gt;
       - NGATE_YAML=/app/ngate.yaml&lt;br /&gt;
     depends_on:&lt;br /&gt;
       - nostr-relay&lt;br /&gt;
     networks:&lt;br /&gt;
       - relaynet&lt;br /&gt;
&lt;br /&gt;
(Note: &amp;lt;code&amp;gt;./src/scripts&amp;lt;/code&amp;gt; instead of &amp;lt;code&amp;gt;./scripts&amp;lt;/code&amp;gt; because&lt;br /&gt;
we kept the cloned repo at &amp;lt;code&amp;gt;src/&amp;lt;/code&amp;gt; and symlinked. If you did it&lt;br /&gt;
differently, adjust the volume paths.)&lt;br /&gt;
&lt;br /&gt;
Make sure to pre-create the state.json so the bind-mount works:&lt;br /&gt;
&lt;br /&gt;
 echo &#039;{&amp;quot;last_run&amp;quot;:&amp;quot;&amp;quot;,&amp;quot;last_apply&amp;quot;:&amp;quot;&amp;quot;,&amp;quot;restart_log&amp;quot;:[],&amp;quot;candidates&amp;quot;:{}}&#039; &amp;gt; /opt/nostr-relay/state.json&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;⚠ Security note&#039;&#039;&#039;: the sidecar mounts &amp;lt;code&amp;gt;/var/run/docker.sock&amp;lt;/code&amp;gt;&lt;br /&gt;
so it can &amp;lt;code&amp;gt;docker restart nostr-relay&amp;lt;/code&amp;gt;. This gives the sidecar&lt;br /&gt;
container effective root on the host. Acceptable on a single-purpose relay&lt;br /&gt;
box. NOT acceptable on a multi-tenant machine. If that bothers you, set&lt;br /&gt;
&amp;lt;code&amp;gt;restart_command&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;ngate.yaml&amp;lt;/code&amp;gt; to a noop (e.g.&lt;br /&gt;
&amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;) and restart the relay manually after each cycle —&lt;br /&gt;
configuration writes still work; only the restart is skipped.&lt;br /&gt;
&lt;br /&gt;
== Step 10: Build and start the sidecar ==&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 docker compose build ngate-sync&lt;br /&gt;
 docker compose up -d ngate-sync&lt;br /&gt;
&lt;br /&gt;
First build downloads node:20-alpine + yq + docker-cli (~150MB). Subsequent&lt;br /&gt;
builds are fast (cached layers). On startup the sidecar runs&lt;br /&gt;
&amp;lt;code&amp;gt;npm install&amp;lt;/code&amp;gt; inside the bind-mounted scripts dir if&lt;br /&gt;
&amp;lt;code&amp;gt;node_modules&amp;lt;/code&amp;gt; isn&#039;t present — should be quick if you already&lt;br /&gt;
ran step 4.&lt;br /&gt;
&lt;br /&gt;
Check the container&#039;s running:&lt;br /&gt;
&lt;br /&gt;
 docker compose ps ngate-sync&lt;br /&gt;
&lt;br /&gt;
Should show &amp;lt;code&amp;gt;Up&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Step 11: Watch a cycle ==&lt;br /&gt;
&lt;br /&gt;
Tail the logs:&lt;br /&gt;
&lt;br /&gt;
 docker compose logs -f ngate-sync&lt;br /&gt;
&lt;br /&gt;
The sidecar runs ONE cycle immediately at startup, then sleeps for&lt;br /&gt;
&amp;lt;code&amp;gt;scan_interval_seconds&amp;lt;/code&amp;gt; between cycles. The first cycle should&lt;br /&gt;
show:&lt;br /&gt;
&lt;br /&gt;
 [2026-05-13T…] [nostr-hive-book] starting ngate-sync instance=… interval=21600s …&lt;br /&gt;
 [2026-05-13T…] [nostr-hive-book] ─── cycle starting ───&lt;br /&gt;
 ngate-scan: trying https://api.hive.blog …&lt;br /&gt;
 ngate-scan:   ok, N posts&lt;br /&gt;
 ngate-scan: scan complete&lt;br /&gt;
 ngate-verify: OK @cnoobs/call.completenoobs.com — Hive sig + Nostr attestation valid …&lt;br /&gt;
 ngate-verify: OK @v4call/v4call.com — Hive sig + Nostr attestation valid …&lt;br /&gt;
 ngate-verify: OK @hive-book/hive-book.com — Hive sig + Nostr attestation valid …&lt;br /&gt;
 ngate-gate: config: account=escrow mode=or hp_enabled=1 (&amp;gt;=3, delegated=false) …&lt;br /&gt;
 ngate-gate: OK @cnoobs/… — passed via hp hp=…&lt;br /&gt;
 ngate-gate: complete — total=3 passed=3 rejected=0 errors=0&lt;br /&gt;
 ngate-apply: seed: 1 pubkey(s) loaded from /app/seed.toml&lt;br /&gt;
 ngate-apply: current: 3 pubkey(s) in /app/config.toml whitelist&lt;br /&gt;
 ngate-apply: stdin: 3 candidate(s) passed gate this run&lt;br /&gt;
 ngate-apply: summary: add=… keep=… tolerate=0 remove=0 seed=1 → new whitelist size = …&lt;br /&gt;
 ngate-apply: ✓ wrote new whitelist to /app/config.toml&lt;br /&gt;
 ngate-apply: restarting relay: docker restart nostr-relay&lt;br /&gt;
 ngate-apply: ✓ restart succeeded&lt;br /&gt;
 [2026-05-13T…] [nostr-hive-book] ─── cycle complete (apply ok) ───&lt;br /&gt;
 [2026-05-13T…] [nostr-hive-book] sleeping 21600s until next cycle&lt;br /&gt;
&lt;br /&gt;
After this, your &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt;&#039;s nGate-managed block should&lt;br /&gt;
reflect the discovered+gated set.&lt;br /&gt;
&lt;br /&gt;
Verify the relay still works:&lt;br /&gt;
&lt;br /&gt;
 docker compose ps nostr-relay   # Up&lt;br /&gt;
 # From your laptop:&lt;br /&gt;
 nak req -k 1 -a YOUR_HEX wss://nostr.YOUR-DOMAIN&lt;br /&gt;
&lt;br /&gt;
If it returns events, the restart worked cleanly.&lt;br /&gt;
&lt;br /&gt;
== Step 12: Routine monitoring ==&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Status snapshot&#039;&#039;&#039;&lt;br /&gt;
:From inside the sidecar:&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose exec ngate-sync /app/scripts/ngate-status.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
:Or from the host (env-vars on the command):&lt;br /&gt;
:&lt;br /&gt;
:  NGATE_YAML=/opt/nostr-relay/ngate.yaml \&lt;br /&gt;
:  NGATE_STATE_PATH=/opt/nostr-relay/state.json \&lt;br /&gt;
:  NGATE_CONFIG_PATH=/opt/nostr-relay/config.toml \&lt;br /&gt;
:  NGATE_SEED_PATH=/opt/nostr-relay/seed.toml \&lt;br /&gt;
:  /opt/nostr-relay/scripts/ngate-status.sh&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Logs&#039;&#039;&#039;&lt;br /&gt;
:* &amp;lt;code&amp;gt;docker compose logs -f ngate-sync&amp;lt;/code&amp;gt; — live stream&lt;br /&gt;
:* &amp;lt;code&amp;gt;tail -F /opt/nostr-relay/ngate-sync.log&amp;lt;/code&amp;gt; — bind-mounted host file&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Manual sync&#039;&#039;&#039;&lt;br /&gt;
:If you want a fresh cycle right now (don&#039;t wait 6 hours):&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose exec ngate-sync /app/scripts/ngate-sync.sh --once&amp;lt;/code&amp;gt;&lt;br /&gt;
:Or restart the sidecar (runs one cycle on startup):&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose restart ngate-sync&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Inspect state.json&#039;&#039;&#039;&lt;br /&gt;
:Shows per-key first-seen, last-seen, consecutive_misses, source&lt;br /&gt;
:(discovery|seed|removed):&lt;br /&gt;
: &amp;lt;code&amp;gt;jq . /opt/nostr-relay/state.json&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Updating nGate ==&lt;br /&gt;
&lt;br /&gt;
When the repo updates:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay/src&lt;br /&gt;
 git pull&lt;br /&gt;
 cd scripts &amp;amp;&amp;amp; npm install   # in case dep versions changed&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 docker compose build ngate-sync   # only needed if Dockerfile changed&lt;br /&gt;
 docker compose restart ngate-sync&lt;br /&gt;
&lt;br /&gt;
The scripts dir is bind-mounted, so script-only changes take effect on next&lt;br /&gt;
sync without rebuild. Restart picks them up immediately.&lt;br /&gt;
&lt;br /&gt;
== Known gotchas ==&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;&amp;quot;Keychain not detected&amp;quot; in server-announce&#039;&#039;&#039; (browser-side, not nGate)&lt;br /&gt;
:Some browsers don&#039;t expose &amp;lt;code&amp;gt;window.hive_keychain&amp;lt;/code&amp;gt; even with the&lt;br /&gt;
:extension installed (iOS Safari, Brave on iOS, etc.). server-announce.html&lt;br /&gt;
:has a posting-key paste fallback that uses dhive to broadcast directly —&lt;br /&gt;
:scroll past the Keychain button to the &amp;quot;POSTING KEY PASTE&amp;quot; section. Key&lt;br /&gt;
:never leaves your browser.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;nGate-verify rejects with &amp;quot;no nostr_attestation in well-known OR post&amp;quot;&#039;&#039;&#039;&lt;br /&gt;
:Your v4call-server post is pre-attestation, OR the well-known doesn&#039;t have&lt;br /&gt;
:the &amp;lt;code&amp;gt;nostr_attestation&amp;lt;/code&amp;gt; field, OR the post body doesn&#039;t have a&lt;br /&gt;
:&amp;lt;code&amp;gt;NOSTR-ATTESTATION:&amp;lt;/code&amp;gt; base64 line. Re-sign in&lt;br /&gt;
:&amp;lt;code&amp;gt;server-sign.html&amp;lt;/code&amp;gt; (Option B canonical) and re-announce. The&lt;br /&gt;
:full chain produces both the well-known field AND the post-body line in&lt;br /&gt;
:one signed event.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;nGate-verify rejects with &amp;quot;Hive signature DID NOT verify&amp;quot;&#039;&#039;&#039;&lt;br /&gt;
:Most common cause: signature was computed under a DIFFERENT canonical&lt;br /&gt;
:payload shape than what nGate-verify reconstructs. Re-sign with the&lt;br /&gt;
:current server-sign.html (which produces the Option B / SHORT canonical&lt;br /&gt;
:that nGate-verify and v4call&#039;s existing federation code both expect).&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;nGate-verify rejects with &amp;quot;attestation v4call_hive_account tag (X) ≠ post&#039;s HIVE-ACCOUNT (Y)&amp;quot;&#039;&#039;&#039;&lt;br /&gt;
:You signed the attestation with one Hive account name (e.g.&lt;br /&gt;
:&amp;lt;code&amp;gt;hive-book&amp;lt;/code&amp;gt;) but the Hive post declares a different one (e.g.&lt;br /&gt;
:&amp;lt;code&amp;gt;hive-book.com&amp;lt;/code&amp;gt;). Re-sign with consistent values across the&lt;br /&gt;
:whole flow (server-sign → server-announce → Hive post).&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Sidecar can&#039;t restart relay (&amp;quot;permission denied&amp;quot; on docker.sock)&#039;&#039;&#039;&lt;br /&gt;
:Less common, but if the relay box has docker access locked down: either&lt;br /&gt;
:loosen perms on &amp;lt;code&amp;gt;/var/run/docker.sock&amp;lt;/code&amp;gt; (default world-writable&lt;br /&gt;
:on most distros) OR change &amp;lt;code&amp;gt;restart_command&amp;lt;/code&amp;gt; in ngate.yaml to&lt;br /&gt;
:a noop and restart the relay manually after each sync.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Sidecar in a restart loop&#039;&#039;&#039;&lt;br /&gt;
:Probably an invalid &amp;lt;code&amp;gt;ngate.yaml&amp;lt;/code&amp;gt; or missing&lt;br /&gt;
:&amp;lt;code&amp;gt;node_modules&amp;lt;/code&amp;gt;. Check:&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose logs --tail=50 ngate-sync&amp;lt;/code&amp;gt;&lt;br /&gt;
:The startup log shows YAML parse errors clearly. If&lt;br /&gt;
:&amp;lt;code&amp;gt;node_modules&amp;lt;/code&amp;gt; is missing the sidecar will install it on first&lt;br /&gt;
:start; subsequent runs are fast.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;&amp;quot;Restart cap hit&amp;quot; in logs&#039;&#039;&#039;&lt;br /&gt;
:nGate refuses to restart the relay more than&lt;br /&gt;
:&amp;lt;code&amp;gt;max_restarts_per_day&amp;lt;/code&amp;gt; times in 24h. If you see this&lt;br /&gt;
:repeatedly, something upstream is causing the whitelist to flap.&lt;br /&gt;
:Investigate state.json and check whether candidates are missing-then-back&lt;br /&gt;
:repeatedly (suggests Hive RPC flakiness or a v4call-server post&lt;br /&gt;
:disappearing temporarily).&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;&amp;quot;Sanity bound triggered&amp;quot; in logs&#039;&#039;&#039;&lt;br /&gt;
:nGate refused to apply because the result would have removed every&lt;br /&gt;
:non-seed entry. Triggered when upstream had errors AND&lt;br /&gt;
:&amp;lt;code&amp;gt;--allow-removals&amp;lt;/code&amp;gt; was somehow set. With the sidecar&#039;s&lt;br /&gt;
:PIPESTATUS-aware logic, this shouldn&#039;t happen in practice; if it does,&lt;br /&gt;
:check upstream phase exit codes.&lt;br /&gt;
&lt;br /&gt;
== Removing nGate (back to manual whitelist) ==&lt;br /&gt;
&lt;br /&gt;
If you want to disable nGate and go back to hand-editing&lt;br /&gt;
&amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 docker compose down ngate-sync&lt;br /&gt;
 docker compose rm -f ngate-sync&lt;br /&gt;
&lt;br /&gt;
Then manually edit &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
 nano /opt/nostr-relay/config.toml&lt;br /&gt;
&lt;br /&gt;
Either:&lt;br /&gt;
&lt;br /&gt;
* Delete the BEGIN/END marker comments (keep the&lt;br /&gt;
  &amp;lt;code&amp;gt;[authorization]&amp;lt;/code&amp;gt; section between them as-is), OR&lt;br /&gt;
* Replace the entire managed block with a hand-curated whitelist.&lt;br /&gt;
&lt;br /&gt;
Restart the relay to pick up the new config:&lt;br /&gt;
&lt;br /&gt;
 docker compose restart nostr-relay&lt;br /&gt;
&lt;br /&gt;
The relay reads &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; on startup regardless of markers.&lt;br /&gt;
nGate&#039;s markers are JUST for nGate&#039;s internal &amp;quot;what to manage&amp;quot; logic; the&lt;br /&gt;
relay itself ignores them as TOML comments.&lt;br /&gt;
&lt;br /&gt;
== When you&#039;re done ==&lt;br /&gt;
&lt;br /&gt;
After 24 hours of clean runs, nGate has handled at least one normal cycle&lt;br /&gt;
(scan + verify + gate + maybe-apply). At that point you can:&lt;br /&gt;
&lt;br /&gt;
* Stake some HIVE on your test operator to confirm a transition (off-gate&lt;br /&gt;
  → passing the gate after stake).&lt;br /&gt;
* Publish a v4call-server post for a new operator and watch it get picked&lt;br /&gt;
  up on the next cycle.&lt;br /&gt;
* Deploy nGate to your second relay box (nostr.v4call.com) with a&lt;br /&gt;
  staggered scan time (set &amp;lt;code&amp;gt;scan_interval_seconds&amp;lt;/code&amp;gt; the same but&lt;br /&gt;
  schedule the startup at a different time of day for faster&lt;br /&gt;
  federation-wide pickup).&lt;br /&gt;
&lt;br /&gt;
For the next phase of work (stage 3.6 refinements + stage 4 strfry&lt;br /&gt;
migration), see [[STATUS.md]] in the nGate repo.&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=V4call-server-data-flow&amp;diff=758</id>
		<title>V4call-server-data-flow</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=V4call-server-data-flow&amp;diff=758"/>
		<updated>2026-05-26T00:36:05Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;{{:LICENCE_HEADER_MIT}}   = v4call Server Data Flow — Sign, Announce, Verify, Gate — A Noob Reference =  From CompleteNoobs  A clear-cut reference for which value goes in which field across &amp;lt;code&amp;gt;server-sign.html&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;server-announce.html&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;/.well-known/v4call-server.json&amp;lt;/code&amp;gt; file, the Hive announce post, and the four nGate scripts. Use this when you&amp;#039;re not 100% sure what to type into a form, or when nGate-verify gives you an error and you...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_MIT}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= v4call Server Data Flow — Sign, Announce, Verify, Gate — A Noob Reference =&lt;br /&gt;
&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
&lt;br /&gt;
A clear-cut reference for which value goes in which field across &amp;lt;code&amp;gt;server-sign.html&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;server-announce.html&amp;lt;/code&amp;gt;, the &amp;lt;code&amp;gt;/.well-known/v4call-server.json&amp;lt;/code&amp;gt; file, the Hive announce post, and the four nGate scripts. Use this when you&#039;re not 100% sure what to type into a form, or when nGate-verify gives you an error and you don&#039;t know which value caused it.&lt;br /&gt;
&lt;br /&gt;
This doc complements [[Nostr_Relay_With_a_3-Key_Whitelist|stage 1]], [[Nostr_Hands-On|stage 2]], and [[NGate_—_Auto-Whitelist_a_Nostr_Relay|stage 3]] — it&#039;s not a phase, it&#039;s a &amp;quot;wait, I&#039;m confused, what&#039;s what?&amp;quot; reference you can flip to any time.&lt;br /&gt;
&lt;br /&gt;
== The single biggest source of confusion: &#039;&#039;&#039;3 different &amp;quot;accounts&amp;quot;&#039;&#039;&#039; ==&lt;br /&gt;
&lt;br /&gt;
These are three &#039;&#039;&#039;separate&#039;&#039;&#039; things. They might happen to share names. They are not the same.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Thing !! Example !! What it is !! Where it lives&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;HIVE ACCOUNT&#039;&#039;&#039; || &amp;lt;code&amp;gt;cnoobs&amp;lt;/code&amp;gt; || A Hive blockchain account. Has a posting key, an active key, and an owner key. Signs the announce post. || On Hive (chain). Resolved by name lookup.&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;ESCROW ACCOUNT&#039;&#039;&#039; || &amp;lt;code&amp;gt;cnoobs-escrow&amp;lt;/code&amp;gt; || ANOTHER Hive blockchain account, used by v4call-server to hold caller funds during paid calls. Its active key must be in &amp;lt;code&amp;gt;V4CALL_ESCROW_KEY&amp;lt;/code&amp;gt; on the v4call server. Default nGate gate target. || On Hive (chain). Different from the HIVE ACCOUNT above. Could be the same account, but usually isn&#039;t (operational separation).&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;DOMAIN&#039;&#039;&#039; || &amp;lt;code&amp;gt;call.completenoobs.com&amp;lt;/code&amp;gt; || An &#039;&#039;&#039;internet hostname&#039;&#039;&#039;. Has DNS records. Hosts your v4call server&#039;s HTTPS endpoint AND the &amp;lt;code&amp;gt;.well-known/v4call-server.json&amp;lt;/code&amp;gt; file. || In DNS. Has nothing to do with Hive — domains are not blockchain accounts.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Common confusion patterns&#039;&#039;&#039; from real test sessions:&lt;br /&gt;
* Putting &amp;lt;code&amp;gt;call.completenoobs.com&amp;lt;/code&amp;gt; (a domain) into the HIVE ACCOUNT field. Hive accounts don&#039;t have dots-and-tlds; &amp;lt;code&amp;gt;call.completenoobs.com&amp;lt;/code&amp;gt; is the DOMAIN field.&lt;br /&gt;
* Putting &amp;lt;code&amp;gt;hive-book.com&amp;lt;/code&amp;gt; (the domain) into the HIVE ACCOUNT field, signing it, and ending up with a well-known that says &amp;lt;code&amp;gt;&amp;quot;hive_account&amp;quot;: &amp;quot;hive-book.com&amp;quot;&amp;lt;/code&amp;gt;. nGate-verify catches it and rejects.&lt;br /&gt;
* Staking HP on &amp;lt;code&amp;gt;v4call&amp;lt;/code&amp;gt; (the announcing HIVE ACCOUNT) when the gate is checking &amp;lt;code&amp;gt;v4call-escrow&amp;lt;/code&amp;gt; (the escrow). Staking on the wrong account ≠ staking.&lt;br /&gt;
&lt;br /&gt;
== The data flow (what goes where) ==&lt;br /&gt;
&lt;br /&gt;
 ┌──────────────────────────────────────────────────────────────────────────┐&lt;br /&gt;
 │                            YOU (operator)                                │&lt;br /&gt;
 │                Decide your 3 accounts + your domain first                │&lt;br /&gt;
 └────────────────────────────┬─────────────────────────────────────────────┘&lt;br /&gt;
                              │&lt;br /&gt;
                              ▼&lt;br /&gt;
 ┌──────────────────────────────────────────────────────────────────────────┐&lt;br /&gt;
 │  Step 1 — server-sign.html                                               │&lt;br /&gt;
 │                                                                          │&lt;br /&gt;
 │  You fill in the IDENTITY card and SERVER CONFIG card and (optionally)   │&lt;br /&gt;
 │  the NOSTR card. Then click &amp;quot;Sign with Hive Keychain&amp;quot;.                   │&lt;br /&gt;
 │                                                                          │&lt;br /&gt;
 │  KEYCHAIN signs a &amp;quot;|&amp;quot;-joined canonical string with the HIVE ACCOUNT&#039;s    │&lt;br /&gt;
 │  posting key. Output is a single signed JSON file.                       │&lt;br /&gt;
 └────────────────────────────┬─────────────────────────────────────────────┘&lt;br /&gt;
                              │&lt;br /&gt;
                              │ Download JSON →&lt;br /&gt;
                              │ deploy to server&#039;s filesystem at&lt;br /&gt;
                              │ /public/.well-known/v4call-server.json&lt;br /&gt;
                              │ Rebuild + restart the v4call container.&lt;br /&gt;
                              ▼&lt;br /&gt;
 ┌──────────────────────────────────────────────────────────────────────────┐&lt;br /&gt;
 │  https://YOUR-DOMAIN/.well-known/v4call-server.json                      │&lt;br /&gt;
 │                                                                          │&lt;br /&gt;
 │  Anyone in the world can fetch this. It&#039;s the cryptographic ground       │&lt;br /&gt;
 │  truth: &amp;quot;This Hive account vouches for this domain.&amp;quot;                     │&lt;br /&gt;
 └────────────────────────────┬─────────────────────────────────────────────┘&lt;br /&gt;
                              │&lt;br /&gt;
                              │ &amp;quot;Next step →&amp;quot; link in server-sign.html&lt;br /&gt;
                              │ opens server-announce.html with all the&lt;br /&gt;
                              │ same fields prefilled via querystring&lt;br /&gt;
                              ▼&lt;br /&gt;
 ┌──────────────────────────────────────────────────────────────────────────┐&lt;br /&gt;
 │  Step 2 — server-announce.html                                           │&lt;br /&gt;
 │                                                                          │&lt;br /&gt;
 │  Fields are auto-filled from server-sign. Verify they match. Click       │&lt;br /&gt;
 │  &amp;quot;Post to Hive&amp;quot;. KEYCHAIN signs a Hive post broadcast.                   │&lt;br /&gt;
 └────────────────────────────┬─────────────────────────────────────────────┘&lt;br /&gt;
                              │&lt;br /&gt;
                              │ Hive consensus stores the post forever.&lt;br /&gt;
                              │ Tagged &amp;quot;v4call-server&amp;quot;.&lt;br /&gt;
                              ▼&lt;br /&gt;
 ┌──────────────────────────────────────────────────────────────────────────┐&lt;br /&gt;
 │  HIVE BLOCKCHAIN — the public directory of v4call servers                │&lt;br /&gt;
 │                                                                          │&lt;br /&gt;
 │  Other nGate operators query this for new candidates.                    │&lt;br /&gt;
 └────────────────────────────┬─────────────────────────────────────────────┘&lt;br /&gt;
                              │&lt;br /&gt;
                              │ ngate-scan.sh queries Hive&lt;br /&gt;
                              ▼&lt;br /&gt;
 ┌──────────────────────────────────────────────────────────────────────────┐&lt;br /&gt;
 │  ngate-scan.sh — Phase 3.1                                               │&lt;br /&gt;
 │                                                                          │&lt;br /&gt;
 │  Reads each post&#039;s V4CALL-SERVER-V1 block, emits NDJSON candidate.       │&lt;br /&gt;
 └────────────────────────────┬─────────────────────────────────────────────┘&lt;br /&gt;
                              │&lt;br /&gt;
                              ▼&lt;br /&gt;
 ┌──────────────────────────────────────────────────────────────────────────┐&lt;br /&gt;
 │  ngate-verify.sh — Phase 3.2                                             │&lt;br /&gt;
 │                                                                          │&lt;br /&gt;
 │  For each candidate, fetches the well-known JSON and runs FIVE checks:   │&lt;br /&gt;
 │  ─────────────────────────────────────────────────────────────────────   │&lt;br /&gt;
 │                                                                          │&lt;br /&gt;
 │  ① Reachable: HTTPS GET of verify_url returns 200 + parses as JSON       │&lt;br /&gt;
 │  ② Schema: well-known has claim, signature, hive_account, nonce          │&lt;br /&gt;
 │  ③ HIVE-ACCOUNT cross-check:                                             │&lt;br /&gt;
 │     well-known.hive_account == post&#039;s HIVE-ACCOUNT field                 │&lt;br /&gt;
 │  ④ NOSTR-HEX cross-check (defense in depth, optional):                   │&lt;br /&gt;
 │     well-known.nostr_hex == post&#039;s NOSTR-PUBKEY-HEX (if both present)    │&lt;br /&gt;
 │  ⑤ SIGNATURE cross-check:                                                │&lt;br /&gt;
 │     fetches HIVE-ACCOUNT&#039;s posting pubkey from Hive,                     │&lt;br /&gt;
 │     reproduces canonical &amp;quot;|&amp;quot;-joined payload from well-known fields,      │&lt;br /&gt;
 │     verifies signature using @hiveio/dhive                               │&lt;br /&gt;
 └────────────────────────────┬─────────────────────────────────────────────┘&lt;br /&gt;
                              │&lt;br /&gt;
                              ▼&lt;br /&gt;
 ┌──────────────────────────────────────────────────────────────────────────┐&lt;br /&gt;
 │  ngate-gate.sh — Phase 3.3                                               │&lt;br /&gt;
 │                                                                          │&lt;br /&gt;
 │  For each verified candidate, picks the gate target:                     │&lt;br /&gt;
 │   • NGATE_GATE_ACCOUNT=escrow (default) → checks ESCROW account          │&lt;br /&gt;
 │   • NGATE_GATE_ACCOUNT=hive_account     → checks HIVE-ACCOUNT             │&lt;br /&gt;
 │                                                                          │&lt;br /&gt;
 │  Fetches HP from Hive, fetches token balance from Hive-Engine,           │&lt;br /&gt;
 │  applies thresholds and OR/AND mode, emits passing candidates.           │&lt;br /&gt;
 └──────────────────────────────────────────────────────────────────────────┘&lt;br /&gt;
&lt;br /&gt;
== server-sign.html — field-by-field reference ==&lt;br /&gt;
&lt;br /&gt;
What to put in each field, what value type, where it ends up. Every field is in BOTH the signed JSON AND (later, by querystring chain) the announce post.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Field !! Type !! Example !! ⚠ Common mistakes&lt;br /&gt;
|-&lt;br /&gt;
| HIVE ACCOUNT (the one that signs) || Hive account name || &amp;lt;code&amp;gt;cnoobs&amp;lt;/code&amp;gt; || ❌ Putting the domain (&amp;lt;code&amp;gt;cnoobs.com&amp;lt;/code&amp;gt;). ❌ Putting an email. ❌ Putting a Nostr npub.&lt;br /&gt;
|-&lt;br /&gt;
| DOMAIN || Hostname (no scheme, no path) || &amp;lt;code&amp;gt;call.completenoobs.com&amp;lt;/code&amp;gt; || ❌ Adding &amp;lt;code&amp;gt;https://&amp;lt;/code&amp;gt; or trailing slash. ❌ Putting a Hive account name.&lt;br /&gt;
|-&lt;br /&gt;
| ESCROW ACCOUNT || Hive account name || &amp;lt;code&amp;gt;cnoobs-escrow&amp;lt;/code&amp;gt; || ❌ Same as HIVE ACCOUNT (allowed but not recommended — operational separation matters). ❌ Putting an account whose active key isn&#039;t on the v4call server.&lt;br /&gt;
|-&lt;br /&gt;
| FEE ACCOUNT || Hive account name || &amp;lt;code&amp;gt;cnoobs&amp;lt;/code&amp;gt; || Often same as HIVE ACCOUNT. ❌ A different chain&#039;s account.&lt;br /&gt;
|-&lt;br /&gt;
| FEDERATION-WS || WebSocket URL || &amp;lt;code&amp;gt;wss://call.completenoobs.com/federation&amp;lt;/code&amp;gt; || ❌ Missing &amp;lt;code&amp;gt;wss://&amp;lt;/code&amp;gt;. ❌ Wrong domain. ❌ Forgetting &amp;lt;code&amp;gt;/federation&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| NOSTR PUBKEY (npub) || Bech32 string starting &amp;lt;code&amp;gt;npub1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;npub1cxgaen…q57qw3d&amp;lt;/code&amp;gt; || ❌ Pasting an nsec (the secret key — never paste secrets into form fields you didn&#039;t write yourself). ❌ Pasting hex into the npub field (use the auto-sync — it&#039;ll auto-fill).&lt;br /&gt;
|-&lt;br /&gt;
| NOSTR PUBKEY (hex) || 64-char lowercase hex || &amp;lt;code&amp;gt;c191dccd…1ba12c&amp;lt;/code&amp;gt; || ❌ Uppercase. ❌ Wrong length. (Auto-fills if you typed a valid npub above.)&lt;br /&gt;
|-&lt;br /&gt;
| NOSTR RELAY 1–5 || WebSocket URLs || &amp;lt;code&amp;gt;wss://nostr.v4call.com&amp;lt;/code&amp;gt; || ❌ Missing &amp;lt;code&amp;gt;wss://&amp;lt;/code&amp;gt;. (Slots 3–5 left blank are silently skipped.)&lt;br /&gt;
|-&lt;br /&gt;
| ISSUED || Auto-filled || timestamp at sign time || (Don&#039;t touch — auto-filled by server-sign.)&lt;br /&gt;
|-&lt;br /&gt;
| NONCE || Auto-filled || random hex || (Don&#039;t touch — auto-filled.)&lt;br /&gt;
|-&lt;br /&gt;
| EXPIRES || Optional date || &amp;lt;code&amp;gt;2027-01-01&amp;lt;/code&amp;gt; || (Most operators leave blank. Set only if you have a specific rotation schedule.)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
After signing, you &#039;&#039;&#039;download&#039;&#039;&#039; the signed JSON and &#039;&#039;&#039;deploy&#039;&#039;&#039; it to:&lt;br /&gt;
* The v4call repo&#039;s &amp;lt;code&amp;gt;public/.well-known/v4call-server.json&amp;lt;/code&amp;gt; (overwriting the placeholder).&lt;br /&gt;
* Rebuild + restart: &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Test reachable: &amp;lt;code&amp;gt;curl https://YOUR-DOMAIN/.well-known/v4call-server.json&amp;lt;/code&amp;gt; should return your file.&lt;br /&gt;
&lt;br /&gt;
== server-announce.html — field-by-field reference ==&lt;br /&gt;
&lt;br /&gt;
server-announce auto-fills from server-sign&#039;s &amp;quot;Next step →&amp;quot; link. Verify the prefilled fields match what you signed. Don&#039;t post mismatched data.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Field !! Same as server-sign? !! Why it&#039;s here too&lt;br /&gt;
|-&lt;br /&gt;
| HIVE ACCOUNT (poster) || YES — must match || Hive consensus needs to know who&#039;s authoring this post.&lt;br /&gt;
|-&lt;br /&gt;
| DOMAIN || YES || nGate-scan reads this from the post; ngate-verify uses it to fetch the well-known.&lt;br /&gt;
|-&lt;br /&gt;
| ESCROW ACCOUNT || YES || Default gate target. Visible in the directory entry.&lt;br /&gt;
|-&lt;br /&gt;
| FEE ACCOUNT || YES || Visible in the directory entry. Informational.&lt;br /&gt;
|-&lt;br /&gt;
| FEDERATION-WS || YES || How peer servers connect for federation.&lt;br /&gt;
|-&lt;br /&gt;
| VERIFY URL (auto) || Auto-computed from DOMAIN || &amp;lt;code&amp;gt;https://DOMAIN/.well-known/v4call-server.json&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| SOFTWARE / PROTOCOL || Optional || Forks may put their own software name here.&lt;br /&gt;
|-&lt;br /&gt;
| NOSTR fields || YES (auto-filled from sign) || Optional. Lets nGate-scan pick up the Nostr key claim.&lt;br /&gt;
|-&lt;br /&gt;
| POST TAGS || &amp;lt;code&amp;gt;v4call-server, v4call, hive&amp;lt;/code&amp;gt; || First tag MUST be &amp;lt;code&amp;gt;v4call-server&amp;lt;/code&amp;gt; — that&#039;s what nGate-scan searches for.&lt;br /&gt;
|-&lt;br /&gt;
| INTRO PARAGRAPH || Optional human readable || Just the prose at the top of the post. Doesn&#039;t affect any field parsing.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
After clicking &amp;quot;Post to Hive&amp;quot; + Keychain confirm, the post is live on Hive. &#039;&#039;&#039;The post is permanent&#039;&#039;&#039; — Hive doesn&#039;t allow deletion. If you made a typo, you can publish a NEW post with the corrected fields; nGate-scan picks up the most recent one for each (account, domain) pair.&lt;br /&gt;
&lt;br /&gt;
== What ends up in the signed JSON ==&lt;br /&gt;
&lt;br /&gt;
The JSON file at &amp;lt;code&amp;gt;/.well-known/v4call-server.json&amp;lt;/code&amp;gt;. Public, fetchable from anywhere, signed by your HIVE ACCOUNT&#039;s posting key.&lt;br /&gt;
&lt;br /&gt;
 {&lt;br /&gt;
   &amp;quot;claim&amp;quot;:         &amp;quot;v4call-server-ownership&amp;quot;,        ← fixed string&lt;br /&gt;
   &amp;quot;domain&amp;quot;:        &amp;quot;call.completenoobs.com&amp;quot;,         ← from DOMAIN&lt;br /&gt;
   &amp;quot;hive_account&amp;quot;:  &amp;quot;cnoobs&amp;quot;,                         ← from HIVE ACCOUNT&lt;br /&gt;
   &amp;quot;escrow&amp;quot;:        &amp;quot;cnoobs-escrow&amp;quot;,                  ← from ESCROW ACCOUNT&lt;br /&gt;
   &amp;quot;fee_account&amp;quot;:   &amp;quot;cnoobs&amp;quot;,                         ← from FEE ACCOUNT&lt;br /&gt;
   &amp;quot;federation_ws&amp;quot;: &amp;quot;wss://call.completenoobs.com/federation&amp;quot;,&lt;br /&gt;
   &amp;quot;issued&amp;quot;:        &amp;quot;2026-05-08T11:18:00Z&amp;quot;,           ← auto&lt;br /&gt;
   &amp;quot;expires&amp;quot;:       &amp;quot;&amp;quot;,                               ← optional&lt;br /&gt;
   &amp;quot;nonce&amp;quot;:         &amp;quot;abc123def456&amp;quot;,                   ← auto&lt;br /&gt;
   &amp;quot;key_type&amp;quot;:      &amp;quot;posting&amp;quot;,                        ← fixed&lt;br /&gt;
   &amp;quot;signature&amp;quot;:     &amp;quot;1f4a3b…&amp;quot;,                        ← Hive ECDSA signature&lt;br /&gt;
&lt;br /&gt;
   // Optional Nostr fields (only present if you filled the NOSTR card):&lt;br /&gt;
   &amp;quot;nostr_npub&amp;quot;:    &amp;quot;npub1cxgaen…q57qw3d&amp;quot;,&lt;br /&gt;
   &amp;quot;nostr_hex&amp;quot;:     &amp;quot;c191dccd…1ba12c&amp;quot;,&lt;br /&gt;
   &amp;quot;nostr_relays&amp;quot;:  [&amp;quot;wss://nostr.v4call.com&amp;quot;, &amp;quot;wss://nostr.hive-book.com&amp;quot;]&lt;br /&gt;
 }&lt;br /&gt;
&lt;br /&gt;
The signature is over a single canonical string built from these fields in a fixed order — server-sign.html computes it; nGate-verify reproduces it. If a single character of any signed field is different between the well-known and what was signed, verification fails.&lt;br /&gt;
&lt;br /&gt;
== What ends up in the Hive post body ==&lt;br /&gt;
&lt;br /&gt;
The announce post body has a markdown-code-block like this (4 leading spaces per line — that&#039;s how Hive renders the block):&lt;br /&gt;
&lt;br /&gt;
     [V4CALL-SERVER-V1]&lt;br /&gt;
     DOMAIN: call.completenoobs.com           ← from DOMAIN field&lt;br /&gt;
     HIVE-ACCOUNT: cnoobs                     ← from HIVE ACCOUNT field&lt;br /&gt;
     ESCROW: cnoobs-escrow                    ← from ESCROW ACCOUNT field&lt;br /&gt;
     FEE-ACCOUNT: cnoobs                      ← from FEE ACCOUNT field&lt;br /&gt;
     FEDERATION-WS: wss://call.completenoobs.com/federation&lt;br /&gt;
     VERIFY-URL: https://call.completenoobs.com/.well-known/v4call-server.json&lt;br /&gt;
     SOFTWARE: v4call&lt;br /&gt;
     PROTOCOL: 0.3&lt;br /&gt;
     NOSTR-PUBKEY: npub1cxgaen…q57qw3d        ← from NOSTR NPUB field&lt;br /&gt;
     NOSTR-PUBKEY-HEX: c191dccd…1ba12c        ← from NOSTR HEX field&lt;br /&gt;
     NOSTR-RELAYS: wss://nostr.v4call.com, wss://nostr.hive-book.com&lt;br /&gt;
     DECLARED: 2026-05-08T11:18:30Z           ← timestamp at post time&lt;br /&gt;
     [/V4CALL-SERVER-V1]&lt;br /&gt;
&lt;br /&gt;
ngate-scan reads exactly this block. Field names are matched case-sensitively after stripping leading whitespace. Lines that don&#039;t match a known field are ignored.&lt;br /&gt;
&lt;br /&gt;
== What nGate-verify cross-checks (and what each rejection means) ==&lt;br /&gt;
&lt;br /&gt;
In order — verify stops at the first failure for each candidate.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Check !! Failure looks like !! Real-world cause !! Fix&lt;br /&gt;
|-&lt;br /&gt;
| Reachable + valid JSON || &amp;lt;code&amp;gt;could not fetch verify_url&amp;lt;/code&amp;gt; || Domain not deployed yet, DNS not propagated, container not rebuilt after dropping in the new well-known file || Deploy the JSON, rebuild + restart container, test with curl&lt;br /&gt;
|-&lt;br /&gt;
| Schema fields exist || &amp;lt;code&amp;gt;well-known missing required fields&amp;lt;/code&amp;gt; || Old or corrupted JSON file || Re-sign via server-sign.html, redeploy&lt;br /&gt;
|-&lt;br /&gt;
| Claim string == &amp;quot;v4call-server-ownership&amp;quot; || &amp;lt;code&amp;gt;wrong claim string&amp;lt;/code&amp;gt; || Someone deployed a non-v4call signed JSON || (Won&#039;t happen if you used server-sign.html)&lt;br /&gt;
|-&lt;br /&gt;
| HIVE ACCOUNT match || &amp;lt;code&amp;gt;well-known hive_account (X) ≠ post&#039;s HIVE-ACCOUNT (Y)&amp;lt;/code&amp;gt; || &#039;&#039;&#039;You typed a different value in the HIVE ACCOUNT field on server-sign vs. on server-announce&#039;&#039;&#039; (or you re-signed and forgot to re-announce) || Re-sign with the correct HIVE ACCOUNT, redeploy, re-announce&lt;br /&gt;
|-&lt;br /&gt;
| Nostr hex match (if both have it) || &amp;lt;code&amp;gt;well-known nostr_hex differs from post&amp;lt;/code&amp;gt; || You signed with one Nostr key, then re-announced with a different one (typo or rotation) || Re-sign with the correct NOSTR HEX, redeploy, re-announce&lt;br /&gt;
|-&lt;br /&gt;
| Expires not in the past || &amp;lt;code&amp;gt;well-known expired at X&amp;lt;/code&amp;gt; || You set an EXPIRES date that has already passed || Re-sign with a fresh ISSUED timestamp (and either no expiry or a future one)&lt;br /&gt;
|-&lt;br /&gt;
| Within-scan hex collision || &amp;lt;code&amp;gt;FORGERY-FLAG hex=… claimed by both @A and @B — both will be REJECTED&amp;lt;/code&amp;gt; || Two operators announcing with the same Nostr hex (one is forged) || Both REJECTED on purpose. Only one operator should hold each Nostr keypair. Re-keying anyone caught in this is mandatory&lt;br /&gt;
|-&lt;br /&gt;
| Signature valid || &amp;lt;code&amp;gt;signature DID NOT verify&amp;lt;/code&amp;gt; || The well-known was tampered with after signing, OR you signed with a different account&#039;s posting key by mistake || Re-sign via server-sign.html (fresh issued + nonce), redeploy&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== What nGate-gate looks at (and which account matters) ==&lt;br /&gt;
&lt;br /&gt;
By default, nGate-gate checks the &#039;&#039;&#039;ESCROW account&#039;&#039;&#039;, not the announcing HIVE ACCOUNT.&lt;br /&gt;
&lt;br /&gt;
;Why escrow?&lt;br /&gt;
:Escrow is the operational account — it actually moves money during paid calls. Gating on its HP/RC enforces &amp;quot;this server can actually operate,&amp;quot; not just &amp;quot;the operator owns a Hive account.&amp;quot;&lt;br /&gt;
&lt;br /&gt;
;When you&#039;d switch to &amp;lt;code&amp;gt;NGATE_GATE_ACCOUNT=hive_account&amp;lt;/code&amp;gt;:&lt;br /&gt;
* Testing with a token gate where the HIVE-ACCOUNT holds the test tokens but the escrow doesn&#039;t (e.g. CNOOBS).&lt;br /&gt;
* Specific governance use cases where the announcer&#039;s reputation matters more than the operational account&#039;s solvency.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Worked example from a real test session&#039;&#039;&#039; (yours):&lt;br /&gt;
* You staked 3 HP on &amp;lt;code&amp;gt;v4call&amp;lt;/code&amp;gt; (the HIVE-ACCOUNT).&lt;br /&gt;
* nGate-gate&#039;s default = check ESCROW = &amp;lt;code&amp;gt;v4call-escrow&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &amp;lt;code&amp;gt;v4call-escrow&amp;lt;/code&amp;gt; still has 0 HP.&lt;br /&gt;
* Result: rejected with &amp;lt;code&amp;gt;gate→@v4call-escrow … hp=0.000/3=false&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Fix A (no Hive transactions): &amp;lt;code&amp;gt;NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_HP=3 ./ngate-gate.sh&amp;lt;/code&amp;gt; → checks &amp;lt;code&amp;gt;v4call&amp;lt;/code&amp;gt;&#039;s HP, passes.&lt;br /&gt;
* Fix B (matches default architecture): Power Up 3 HIVE on &amp;lt;code&amp;gt;v4call-escrow&amp;lt;/code&amp;gt; directly.&lt;br /&gt;
&lt;br /&gt;
== Common mistakes — based on real test sessions ==&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Mistake #1: Putting the domain in HIVE ACCOUNT&#039;&#039;&#039;&lt;br /&gt;
:Result: well-known has &amp;lt;code&amp;gt;&amp;quot;hive_account&amp;quot;: &amp;quot;your-domain.com&amp;quot;&amp;lt;/code&amp;gt;. nGate-verify rejects with &amp;lt;code&amp;gt;well-known hive_account (your-domain.com) ≠ post&#039;s HIVE-ACCOUNT (your-account)&amp;lt;/code&amp;gt;.&lt;br /&gt;
:Fix: server-sign.html with HIVE ACCOUNT = &amp;lt;code&amp;gt;your-account&amp;lt;/code&amp;gt; (just the Hive name, no .com).&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Mistake #2: Staking HP on the wrong account&#039;&#039;&#039;&lt;br /&gt;
:Result: gate fails for an operator you&#039;d expect to pass.&lt;br /&gt;
:Fix: read the &amp;lt;code&amp;gt;(gate→@account)&amp;lt;/code&amp;gt; in the rejection — it tells you which account was checked. Stake there, OR change &amp;lt;code&amp;gt;NGATE_GATE_ACCOUNT&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Mistake #3: Not re-announcing after re-signing&#039;&#039;&#039;&lt;br /&gt;
:Result: well-known has updated fields, but the post on Hive still has the old ones. nGate-verify cross-checks see a mismatch.&lt;br /&gt;
:Fix: &#039;&#039;&#039;always do server-sign + server-announce together as a pair&#039;&#039;&#039;. The &amp;quot;Next step →&amp;quot; button on server-sign goes to server-announce with prefilled fields exactly to keep these in sync.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Mistake #4: Pre-Nostr announce posts still in the scan window&#039;&#039;&#039;&lt;br /&gt;
:Symptom: &amp;lt;code&amp;gt;skip @user/domain — no nostr_pubkey_hex (pre-Nostr announce; not an error)&amp;lt;/code&amp;gt;.&lt;br /&gt;
:Cause: you announced before nostr fields were added; the old post still appears in Hive&#039;s recent-20 query.&lt;br /&gt;
:Fix: nothing required. Each operator only needs ONE current announce; older posts age out. If you want the relay to stop seeing them sooner, just publish a fresh announce — your latest takes precedence in Hive&#039;s recent-by-created order.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Mistake #5: Signing without rebuilding the container&#039;&#039;&#039;&lt;br /&gt;
:Result: deployed the new well-known JSON to disk but the v4call Docker container still serves the old one (Docker images bake in static files at build time).&lt;br /&gt;
:Fix: &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt; after every well-known change.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Mistake #6: Posting before deploying the well-known&#039;&#039;&#039;&lt;br /&gt;
:Result: announce post is live on Hive; nGate-scan picks it up; nGate-verify tries to fetch the well-known and gets a 404 → rejection.&lt;br /&gt;
:Fix: deploy + rebuild + verify with curl FIRST, then announce. Or fix the well-known and wait for the next scan cycle — eventual consistency.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Mistake #7: Staking &amp;quot;exactly N HIVE&amp;quot; but the gate at &amp;lt;code&amp;gt;NGATE_MIN_HP=N&amp;lt;/code&amp;gt; still fails&#039;&#039;&#039;&lt;br /&gt;
:Symptom: &amp;lt;code&amp;gt;REJECT … gate failed hp=2.999/3=false&amp;lt;/code&amp;gt; (or some near-round-number mismatch).&lt;br /&gt;
:Cause: HIVE → VESTS → HP round-trip uses 6-decimal vesting math. Staking exactly 3 HIVE can land at 2.999something HP, not exactly 3.000. The gate&#039;s comparison is correct (&amp;lt;code&amp;gt;&amp;amp;gt;=&amp;lt;/code&amp;gt; not &amp;lt;code&amp;gt;&amp;amp;gt;&amp;lt;/code&amp;gt;), but the input itself is just slightly under the threshold.&lt;br /&gt;
:Fix A: Stake slightly above the threshold (e.g. 4 HIVE for a &amp;lt;code&amp;gt;NGATE_MIN_HP=3&amp;lt;/code&amp;gt; gate).&lt;br /&gt;
:Fix B: Set the threshold slightly below the stake (e.g. &amp;lt;code&amp;gt;NGATE_MIN_HP=2.5&amp;lt;/code&amp;gt;).&lt;br /&gt;
:Both work. The first feels &amp;quot;rounder&amp;quot; to the operator; the second uses fewer HIVE. Either is fine.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Mistake #8 — a transient Hive RPC outage shows up as an ERROR line&#039;&#039;&#039;&lt;br /&gt;
:Symptom: &amp;lt;code&amp;gt;ngate-gate: ERROR … HP fetch failed (transient Hive RPC); not passing&amp;lt;/code&amp;gt; on one or more candidates, with the run summary showing &amp;lt;code&amp;gt;errors=1&amp;lt;/code&amp;gt;+.&lt;br /&gt;
:Cause: Hive node didn&#039;t respond before the script&#039;s timeout. Not your fault; not a config bug.&lt;br /&gt;
:What happens: that candidate is treated as &amp;quot;not passing&amp;quot; for THIS run, so it doesn&#039;t get added/refreshed. The script exits with code 2 (partial fetch errors). Phase 3.4 reads this exit code and refuses to REMOVE any existing entries from the whitelist — so a single bad cycle can&#039;t kick legitimate operators off your relay.&lt;br /&gt;
:Fix: nothing required. Re-run when Hive is healthy. The &amp;quot;ADD-only on partial failure&amp;quot; rule is the design&#039;s safety net; this output is what it looks like working.&lt;br /&gt;
&lt;br /&gt;
== Clean-redo checklist ==&lt;br /&gt;
&lt;br /&gt;
If you&#039;re re-doing an operator&#039;s announce and want to be sure each step is correct, follow this exact order. Tick them off mentally as you go.&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;☐ Confirm your three accounts and one domain&#039;&#039;&#039; on a piece of paper:&lt;br /&gt;
#: HIVE ACCOUNT  = ___________________  (e.g. &amp;lt;code&amp;gt;cnoobs&amp;lt;/code&amp;gt;, no &amp;lt;code&amp;gt;.com&amp;lt;/code&amp;gt;)&lt;br /&gt;
#: ESCROW ACCT   = ___________________  (e.g. &amp;lt;code&amp;gt;cnoobs-escrow&amp;lt;/code&amp;gt;, no &amp;lt;code&amp;gt;.com&amp;lt;/code&amp;gt;)&lt;br /&gt;
#: FEE ACCT      = ___________________  (often same as HIVE ACCOUNT)&lt;br /&gt;
#: DOMAIN        = ___________________  (e.g. &amp;lt;code&amp;gt;call.completenoobs.com&amp;lt;/code&amp;gt;, no scheme/path)&lt;br /&gt;
#: NOSTR NPUB    = ___________________  (from &amp;lt;code&amp;gt;nostr-gen.html&amp;lt;/code&amp;gt;)&lt;br /&gt;
# &#039;&#039;&#039;☐ Open server-sign.html&#039;&#039;&#039;. Fill the IDENTITY card, SERVER CONFIG card, and (optionally) NOSTR card with the values above. Verify each line of the form against your paper.&lt;br /&gt;
# &#039;&#039;&#039;☐ Click &amp;quot;Sign with Hive Keychain&amp;quot;&#039;&#039;&#039;. Confirm in Keychain.&lt;br /&gt;
# &#039;&#039;&#039;☐ Download the signed JSON&#039;&#039;&#039;. Open it in a text editor. Eyeball the top fields — they should match your paper.&lt;br /&gt;
# &#039;&#039;&#039;☐ Deploy the JSON to your v4call repo&#039;&#039;&#039; at &amp;lt;code&amp;gt;public/.well-known/v4call-server.json&amp;lt;/code&amp;gt; (overwriting whatever was there).&lt;br /&gt;
# &#039;&#039;&#039;☐ Rebuild + restart&#039;&#039;&#039; your v4call container: &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt;.&lt;br /&gt;
# &#039;&#039;&#039;☐ Test reachability&#039;&#039;&#039;: &amp;lt;code&amp;gt;curl https://YOUR-DOMAIN/.well-known/v4call-server.json&amp;lt;/code&amp;gt; on your laptop. Should return the same JSON you just deployed.&lt;br /&gt;
# &#039;&#039;&#039;☐ Click the &amp;quot;Next step →&amp;quot; link&#039;&#039;&#039; on server-sign.html (it auto-opens server-announce.html with all fields prefilled from the querystring). Verify the prefilled fields match your paper.&lt;br /&gt;
# &#039;&#039;&#039;☐ Click &amp;quot;Post to Hive&amp;quot;&#039;&#039;&#039;. Confirm in Keychain. Wait for the success banner.&lt;br /&gt;
# &#039;&#039;&#039;☐ Verify end-to-end&#039;&#039;&#039;: from your laptop, run &amp;lt;code&amp;gt;./nGate/scripts/ngate-scan.sh | ./nGate/scripts/ngate-verify.sh&amp;lt;/code&amp;gt;. Look for &amp;lt;code&amp;gt;OK @your-account/your-domain — signature valid&amp;lt;/code&amp;gt;.&lt;br /&gt;
# &#039;&#039;&#039;☐ Test the gate&#039;&#039;&#039;: &amp;lt;code&amp;gt;./ngate-scan.sh | ./ngate-verify.sh | NGATE_MIN_HP=3 ./ngate-gate.sh&amp;lt;/code&amp;gt;. Read the &amp;lt;code&amp;gt;(gate→@account)&amp;lt;/code&amp;gt; on each rejection — that&#039;s the account whose HP it&#039;s checking. Stake there OR pick a different gate target.&lt;br /&gt;
&lt;br /&gt;
If anything fails, the rejection message tells you which check failed — see &amp;quot;What nGate-verify cross-checks&amp;quot; or &amp;quot;What nGate-gate looks at&amp;quot; above for the recipe.&lt;br /&gt;
&lt;br /&gt;
== Quick reference card ==&lt;br /&gt;
&lt;br /&gt;
Print this. Tape it next to your monitor while doing operator work.&lt;br /&gt;
&lt;br /&gt;
 ╔══════════════════════════════════════════════════════════════════════╗&lt;br /&gt;
 ║                  v4call ANNOUNCE QUICK REFERENCE                     ║&lt;br /&gt;
 ╠══════════════════════════════════════════════════════════════════════╣&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║  3 ACCOUNTS + 1 DOMAIN — these are NOT the same thing:               ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║    HIVE ACCOUNT   →  signs the announce      (e.g. cnoobs)           ║&lt;br /&gt;
 ║    ESCROW ACCT    →  holds funds, gate target (e.g. cnoobs-escrow)   ║&lt;br /&gt;
 ║    FEE ACCT       →  receives platform fees   (often = HIVE ACCT)    ║&lt;br /&gt;
 ║    DOMAIN         →  internet hostname        (call.completenoobs.com)║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║  ALWAYS DO TOGETHER (one breaks if you skip the other):              ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║    server-sign.html  ─→  deploy + rebuild  ─→  server-announce.html  ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║  STAKING FOR THE GATE — stake on the right account:                  ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║    NGATE_GATE_ACCOUNT=escrow (default) → stake on ESCROW             ║&lt;br /&gt;
 ║    NGATE_GATE_ACCOUNT=hive_account     → stake on HIVE ACCT          ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║  WHEN VERIFY FAILS — read the rejection reason:                      ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║    &amp;quot;well-known hive_account (X) ≠ post&#039;s HIVE-ACCOUNT (Y)&amp;quot;           ║&lt;br /&gt;
 ║       → typo on one of the two; re-sign with correct value           ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║    &amp;quot;signature DID NOT verify&amp;quot;                                        ║&lt;br /&gt;
 ║       → well-known tampered with OR signed by wrong account          ║&lt;br /&gt;
 ║       → re-sign cleanly                                              ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║    &amp;quot;could not fetch verify_url&amp;quot;                                      ║&lt;br /&gt;
 ║       → JSON not deployed OR container not rebuilt                   ║&lt;br /&gt;
 ║       → docker compose down/build/up, curl-test from laptop          ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║    &amp;quot;skip … no nostr_pubkey_hex&amp;quot;                                      ║&lt;br /&gt;
 ║       → pre-Nostr announce, harmless, ignore                         ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║  DEFAULT GATE TARGET:                                                ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ║    Escrow account. (Operational solvency check.)                     ║&lt;br /&gt;
 ║    To check announcing account instead:                              ║&lt;br /&gt;
 ║       NGATE_GATE_ACCOUNT=hive_account                                ║&lt;br /&gt;
 ║                                                                      ║&lt;br /&gt;
 ╚══════════════════════════════════════════════════════════════════════╝&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=NGate&amp;diff=756</id>
		<title>NGate</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=NGate&amp;diff=756"/>
		<updated>2026-05-08T18:00:37Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;* nGate-dev  Stage 1: https://www.completenoobs.com/noobs/Nostr-relay-with-whitelist  Stage 2: https://www.completenoobs.com/noobs/Nostr-handson&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;* nGate-dev&lt;br /&gt;
&lt;br /&gt;
Stage 1: https://www.completenoobs.com/noobs/Nostr-relay-with-whitelist&lt;br /&gt;
&lt;br /&gt;
Stage 2: https://www.completenoobs.com/noobs/Nostr-handson&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Nostr-handson&amp;diff=755</id>
		<title>Nostr-handson</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Nostr-handson&amp;diff=755"/>
		<updated>2026-05-08T17:59:50Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;{{:LICENCE_HEADER_MIT}}   = Nostr Hands-On — Send Your First Notes and DMs Through Your Own Relays =  From CompleteNoobs  Stage 2 of the Nostr learning path. Stage 1 (nostr-relay-with-whitelist) got you &amp;#039;&amp;#039;two private write-whitelisted relays&amp;#039;&amp;#039; running. This stage gets you actually &amp;#039;&amp;#039;using&amp;#039;&amp;#039; Nostr — sending public notes, sending encrypted DMs, subscribing to events, watching the raw pro...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_MIT}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Nostr Hands-On — Send Your First Notes and DMs Through Your Own Relays =&lt;br /&gt;
&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
&lt;br /&gt;
Stage 2 of the Nostr learning path. Stage 1 ([[Nostr_Relay_With_a_3-Key_Whitelist_—_Two_Servers_on_Ubuntu_24.04_with_Docker|nostr-relay-with-whitelist]]) got you &#039;&#039;two private write-whitelisted relays&#039;&#039; running. This stage gets you actually &#039;&#039;using&#039;&#039; Nostr — sending public notes, sending encrypted DMs, subscribing to events, watching the raw protocol — using a single static HTML page that you can read, modify, and break.&lt;br /&gt;
&lt;br /&gt;
We deliberately skip Primal / nostrudel / Damus etc. for stage 2. Real clients hide the protocol behind a UX, and the noob ends up &amp;quot;using Nostr&amp;quot; without ever seeing what Nostr &#039;&#039;is&#039;&#039;. Instead, you&#039;ll use &amp;lt;code&amp;gt;nostr-handson.html&amp;lt;/code&amp;gt; from this folder — a one-file, no-install learning tool. After 30 minutes with it, you&#039;ll understand more about Nostr than most people who&#039;ve used a client for months.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;End result of stage 2&#039;&#039;&#039;: you can publish a signed Nostr event from your laptop, see it land on your private relay, query it back over the wire, send an encrypted DM between two of your whitelisted identities, and read every JSON message that flowed through the browser&#039;s developer console.&lt;br /&gt;
&lt;br /&gt;
== nostr-handson.html code ==&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;code&amp;gt;nostr-handson.html&amp;lt;/code&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br /&gt;
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;head&amp;gt;&lt;br /&gt;
&amp;lt;meta charset=&amp;quot;UTF-8&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;title&amp;gt;Nostr Hands-On — completenoobs&amp;lt;/title&amp;gt;&lt;br /&gt;
&amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;style&amp;gt;&lt;br /&gt;
:root {&lt;br /&gt;
  --bg: #0d0d0d;&lt;br /&gt;
  --surface: #1a1a1a;&lt;br /&gt;
  --surface2: #252525;&lt;br /&gt;
  --accent: #f5a623;&lt;br /&gt;
  --green: #5cb85c;&lt;br /&gt;
  --blue: #5bc0de;&lt;br /&gt;
  --purple: #9b59b6;&lt;br /&gt;
  --red: #d9534f;&lt;br /&gt;
  --text: #e0e0e0;&lt;br /&gt;
  --subtext: #b0b0b0;&lt;br /&gt;
  --muted: #707070;&lt;br /&gt;
  --border: #333;&lt;br /&gt;
}&lt;br /&gt;
* { box-sizing: border-box; }&lt;br /&gt;
body {&lt;br /&gt;
  background: var(--bg);&lt;br /&gt;
  color: var(--text);&lt;br /&gt;
  font-family: &#039;IBM Plex Sans&#039;, system-ui, sans-serif;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  padding: 20px;&lt;br /&gt;
  max-width: 1100px;&lt;br /&gt;
  margin-left: auto; margin-right: auto;&lt;br /&gt;
}&lt;br /&gt;
header {&lt;br /&gt;
  border-bottom: 2px solid var(--accent);&lt;br /&gt;
  padding-bottom: 10px;&lt;br /&gt;
  margin-bottom: 20px;&lt;br /&gt;
}&lt;br /&gt;
header h1 { margin: 0; color: var(--accent); font-family: &#039;IBM Plex Mono&#039;, monospace; }&lt;br /&gt;
header p { color: var(--subtext); margin: 6px 0 0; font-size: 0.9rem; }&lt;br /&gt;
&lt;br /&gt;
section {&lt;br /&gt;
  background: var(--surface);&lt;br /&gt;
  border: 1px solid var(--border);&lt;br /&gt;
  border-radius: 8px;&lt;br /&gt;
  padding: 14px 16px;&lt;br /&gt;
  margin-bottom: 14px;&lt;br /&gt;
}&lt;br /&gt;
section h2 {&lt;br /&gt;
  margin: 0 0 8px;&lt;br /&gt;
  color: var(--accent);&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
}&lt;br /&gt;
.help {&lt;br /&gt;
  color: var(--subtext);&lt;br /&gt;
  font-size: 0.85rem;&lt;br /&gt;
  margin-bottom: 10px;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
input, textarea, button, select {&lt;br /&gt;
  background: var(--surface2);&lt;br /&gt;
  color: var(--text);&lt;br /&gt;
  border: 1px solid var(--border);&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  padding: 6px 10px;&lt;br /&gt;
  font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
  font-size: 0.85rem;&lt;br /&gt;
}&lt;br /&gt;
input:focus, textarea:focus { border-color: var(--accent); outline: none; }&lt;br /&gt;
button { cursor: pointer; }&lt;br /&gt;
button:hover { border-color: var(--accent); }&lt;br /&gt;
button.primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }&lt;br /&gt;
button.primary:hover { filter: brightness(1.1); }&lt;br /&gt;
button.danger { color: var(--red); border-color: var(--red); }&lt;br /&gt;
button:disabled { opacity: 0.4; cursor: not-allowed; }&lt;br /&gt;
&lt;br /&gt;
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }&lt;br /&gt;
.row &amp;gt; input { flex: 1; min-width: 200px; }&lt;br /&gt;
&lt;br /&gt;
.warn {&lt;br /&gt;
  background: rgba(217,83,79,0.08);&lt;br /&gt;
  border: 1px solid var(--red);&lt;br /&gt;
  color: var(--red);&lt;br /&gt;
  padding: 8px 12px;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  margin-bottom: 10px;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
}&lt;br /&gt;
.tip {&lt;br /&gt;
  background: rgba(245,166,35,0.07);&lt;br /&gt;
  border: 1px solid rgba(245,166,35,0.3);&lt;br /&gt;
  color: var(--subtext);&lt;br /&gt;
  padding: 6px 10px;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  margin-top: 8px;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.tab-buttons { display: flex; gap: 4px; margin-bottom: 10px; }&lt;br /&gt;
.tab-buttons button {&lt;br /&gt;
  border-bottom: 2px solid transparent;&lt;br /&gt;
  border-radius: 4px 4px 0 0;&lt;br /&gt;
}&lt;br /&gt;
.tab-buttons button.active {&lt;br /&gt;
  border-bottom-color: var(--accent);&lt;br /&gt;
  color: var(--accent);&lt;br /&gt;
}&lt;br /&gt;
.tab-pane { display: none; }&lt;br /&gt;
.tab-pane.active { display: block; }&lt;br /&gt;
&lt;br /&gt;
#relay-list { display: flex; flex-direction: column; gap: 6px; }&lt;br /&gt;
.relay-row {&lt;br /&gt;
  display: flex; align-items: center; gap: 8px;&lt;br /&gt;
  background: var(--surface2);&lt;br /&gt;
  padding: 6px 10px;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
}&lt;br /&gt;
.relay-row .url { flex: 1; font-family: &#039;IBM Plex Mono&#039;, monospace; font-size: 0.82rem; min-width: 200px; }&lt;br /&gt;
.relay-row .status {&lt;br /&gt;
  font-size: 0.72rem;&lt;br /&gt;
  font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
  padding: 2px 6px;&lt;br /&gt;
  border-radius: 3px;&lt;br /&gt;
  min-width: 88px;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
.status.disconnected { background: #444; color: var(--muted); }&lt;br /&gt;
.status.connecting { background: var(--purple); color: white; }&lt;br /&gt;
.status.open { background: var(--green); color: white; }&lt;br /&gt;
.status.error { background: var(--red); color: white; }&lt;br /&gt;
.relay-row .msgs { font-size: 0.72rem; color: var(--muted); min-width: 60px; }&lt;br /&gt;
&lt;br /&gt;
textarea { width: 100%; min-height: 70px; resize: vertical; }&lt;br /&gt;
&lt;br /&gt;
#event-log {&lt;br /&gt;
  max-height: 600px;&lt;br /&gt;
  overflow-y: auto;&lt;br /&gt;
  font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
}&lt;br /&gt;
.log-entry {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  margin-bottom: 5px;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  border-left: 3px solid;&lt;br /&gt;
}&lt;br /&gt;
.log-entry.in { background: rgba(91,192,222,0.05); border-left-color: var(--blue); }&lt;br /&gt;
.log-entry.out { background: rgba(245,166,35,0.05); border-left-color: var(--accent); }&lt;br /&gt;
.log-entry.system { background: rgba(112,112,112,0.08); border-left-color: var(--muted); }&lt;br /&gt;
.log-entry.error { background: rgba(217,83,79,0.05); border-left-color: var(--red); }&lt;br /&gt;
.log-entry .head {&lt;br /&gt;
  color: var(--subtext);&lt;br /&gt;
  font-size: 0.72rem;&lt;br /&gt;
  margin-bottom: 3px;&lt;br /&gt;
}&lt;br /&gt;
.log-entry .summary { color: var(--text); margin-bottom: 4px; word-break: break-word; }&lt;br /&gt;
.log-entry pre {&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  white-space: pre-wrap;&lt;br /&gt;
  word-break: break-all;&lt;br /&gt;
  background: var(--bg);&lt;br /&gt;
  padding: 6px;&lt;br /&gt;
  border-radius: 3px;&lt;br /&gt;
  color: var(--subtext);&lt;br /&gt;
  max-height: 240px;&lt;br /&gt;
  overflow-y: auto;&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
  font-size: 0.74rem;&lt;br /&gt;
}&lt;br /&gt;
.log-entry pre.collapsed { max-height: 38px; overflow: hidden; }&lt;br /&gt;
.log-entry .relay-tag {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  background: var(--surface);&lt;br /&gt;
  padding: 1px 6px;&lt;br /&gt;
  border-radius: 3px;&lt;br /&gt;
  font-size: 0.7rem;&lt;br /&gt;
  color: var(--accent);&lt;br /&gt;
  margin-right: 4px;&lt;br /&gt;
}&lt;br /&gt;
.identity-display {&lt;br /&gt;
  font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  word-break: break-all;&lt;br /&gt;
  background: var(--surface2);&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
}&lt;br /&gt;
.identity-display strong { color: var(--accent); display: inline-block; min-width: 110px; }&lt;br /&gt;
.muted { color: var(--muted); }&lt;br /&gt;
small.help-inline { color: var(--muted); font-size: 0.75rem; margin-left: 4px; }&lt;br /&gt;
&amp;lt;/style&amp;gt;&lt;br /&gt;
&amp;lt;/head&amp;gt;&lt;br /&gt;
&amp;lt;body&amp;gt;&lt;br /&gt;
&amp;lt;header&amp;gt;&lt;br /&gt;
  &amp;lt;h1&amp;gt;Nostr Hands-On&amp;lt;/h1&amp;gt;&lt;br /&gt;
  &amp;lt;p&amp;gt;A bare-metal Nostr learning page. One file, no install, no client opinions, no follow lists, no profile rendering. Every WebSocket message is logged in section 5 &amp;lt;strong&amp;gt;and mirrored to the browser console&amp;lt;/strong&amp;gt; (open DevTools / F12). Read the source — it &amp;lt;em&amp;gt;is&amp;lt;/em&amp;gt; the tutorial.&amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;/header&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;section&amp;gt;&lt;br /&gt;
  &amp;lt;h2&amp;gt;1. Identity — load a private key&amp;lt;/h2&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;help&amp;quot;&amp;gt;Paste your &amp;lt;code&amp;gt;nsec1...&amp;lt;/code&amp;gt; or 64-char hex private key. The key stays in this tab&#039;s memory only — it&#039;s never sent to any server, written to disk, or kept after you close the tab.&amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;warn&amp;quot;&amp;gt;&amp;lt;strong&amp;gt;⚠ Use a disposable key.&amp;lt;/strong&amp;gt; The privkey lives in plain JS memory while this tab is open. Browser extensions, page bugs, or future you forgetting which tab is which can all expose it. For real Nostr usage, use a NIP-07 extension (nos2x / Alby) — see the wiki.&amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;row&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;input id=&amp;quot;nsec-input&amp;quot; type=&amp;quot;password&amp;quot; placeholder=&amp;quot;nsec1... or 64-char hex&amp;quot; autocomplete=&amp;quot;off&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;button id=&amp;quot;load-key&amp;quot; class=&amp;quot;primary&amp;quot;&amp;gt;Load Key&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;button id=&amp;quot;forget-key&amp;quot;&amp;gt;Forget&amp;lt;/button&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div id=&amp;quot;identity-out&amp;quot; class=&amp;quot;identity-display muted&amp;quot;&amp;gt;No key loaded.&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/section&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;section&amp;gt;&lt;br /&gt;
  &amp;lt;h2&amp;gt;2. Relays — open WebSockets&amp;lt;/h2&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;help&amp;quot;&amp;gt;Pick which relays to use. Click &amp;lt;strong&amp;gt;Connect&amp;lt;/strong&amp;gt; on each one. Status flips disconnected → connecting → open. Read-only relays don&#039;t need your key. Write relays need your pubkey on the relay&#039;s whitelist (for your private relays, that means you set them up correctly in &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt;).&amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div id=&amp;quot;relay-list&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;row&amp;quot; style=&amp;quot;margin-top:10px;&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;input id=&amp;quot;new-relay&amp;quot; placeholder=&amp;quot;wss://your-relay.example.com&amp;quot; autocomplete=&amp;quot;off&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;button id=&amp;quot;add-relay&amp;quot;&amp;gt;Add relay&amp;lt;/button&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;tip&amp;quot;&amp;gt;A &amp;quot;Connect&amp;quot; failure is almost always: bad URL, relay offline, or your network blocking outbound 443. Check the system log entry for the WebSocket error.&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/section&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;section&amp;gt;&lt;br /&gt;
  &amp;lt;h2&amp;gt;3. Compose — sign and publish events&amp;lt;/h2&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;help&amp;quot;&amp;gt;Build a Nostr event, sign it with your loaded key, and broadcast to every &amp;lt;em&amp;gt;open&amp;lt;/em&amp;gt; relay. The signed event is logged in section 5 — check the JSON to see &amp;lt;code&amp;gt;id&amp;lt;/code&amp;gt; (sha256 of canonical fields), &amp;lt;code&amp;gt;sig&amp;lt;/code&amp;gt; (schnorr), &amp;lt;code&amp;gt;tags&amp;lt;/code&amp;gt;, etc.&amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;tab-buttons&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;button class=&amp;quot;tab-btn active&amp;quot; data-tab=&amp;quot;note&amp;quot;&amp;gt;Public Note (kind 1)&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;button class=&amp;quot;tab-btn&amp;quot; data-tab=&amp;quot;dm&amp;quot;&amp;gt;DM (kind 4 / NIP-04)&amp;lt;/button&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;tab-pane active&amp;quot; data-pane=&amp;quot;note&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;textarea id=&amp;quot;note-content&amp;quot; placeholder=&amp;quot;What&#039;s on your mind? (kind 1 = a public note. The whole world can read this on any relay you publish to.)&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
    &amp;lt;div class=&amp;quot;row&amp;quot; style=&amp;quot;margin-top:8px;&amp;quot;&amp;gt;&lt;br /&gt;
      &amp;lt;button id=&amp;quot;send-note&amp;quot; class=&amp;quot;primary&amp;quot;&amp;gt;Sign &amp;amp;amp; Publish&amp;lt;/button&amp;gt;&lt;br /&gt;
      &amp;lt;small class=&amp;quot;help-inline&amp;quot;&amp;gt;Goes to every relay currently in the open state.&amp;lt;/small&amp;gt;&lt;br /&gt;
    &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;tab-pane&amp;quot; data-pane=&amp;quot;dm&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;input id=&amp;quot;dm-recipient&amp;quot; placeholder=&amp;quot;Recipient npub1... or 64-char hex&amp;quot; autocomplete=&amp;quot;off&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;textarea id=&amp;quot;dm-content&amp;quot; placeholder=&amp;quot;Encrypted message body&amp;quot; style=&amp;quot;margin-top:6px;&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
    &amp;lt;div class=&amp;quot;row&amp;quot; style=&amp;quot;margin-top:8px;&amp;quot;&amp;gt;&lt;br /&gt;
      &amp;lt;button id=&amp;quot;send-dm&amp;quot; class=&amp;quot;primary&amp;quot;&amp;gt;Encrypt, Sign &amp;amp;amp; Publish&amp;lt;/button&amp;gt;&lt;br /&gt;
      &amp;lt;small class=&amp;quot;help-inline&amp;quot;&amp;gt;NIP-04: end-to-end encrypted. Relays see the ciphertext + the recipient&#039;s pubkey, not the body.&amp;lt;/small&amp;gt;&lt;br /&gt;
    &amp;lt;/div&amp;gt;&lt;br /&gt;
    &amp;lt;div class=&amp;quot;tip&amp;quot;&amp;gt;NIP-04 is the original DM scheme — old, deprecated for new clients (NIP-17 is the modern one), but transparent and supported everywhere. Good for learning. &amp;lt;strong&amp;gt;Do not&amp;lt;/strong&amp;gt; use it as your main DM transport long-term.&amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/section&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;section&amp;gt;&lt;br /&gt;
  &amp;lt;h2&amp;gt;4. Subscribe — ask relays for events&amp;lt;/h2&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;help&amp;quot;&amp;gt;A subscription is a &amp;lt;code&amp;gt;REQ&amp;lt;/code&amp;gt; message with one or more filter objects. Relays send matching stored events, then &amp;lt;code&amp;gt;EOSE&amp;lt;/code&amp;gt; (end of stored events), then keep streaming new matches in real time until you &amp;lt;code&amp;gt;CLOSE&amp;lt;/code&amp;gt;.&amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;row&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;button class=&amp;quot;sub-preset&amp;quot; data-preset=&amp;quot;my-events&amp;quot;&amp;gt;My events&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;button class=&amp;quot;sub-preset&amp;quot; data-preset=&amp;quot;my-dms&amp;quot;&amp;gt;DMs to me&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;button class=&amp;quot;sub-preset&amp;quot; data-preset=&amp;quot;lookup-author&amp;quot;&amp;gt;Lookup author…&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;button class=&amp;quot;sub-preset&amp;quot; data-preset=&amp;quot;firehose&amp;quot;&amp;gt;Firehose (latest 20 kind 1)&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;button id=&amp;quot;cancel-subs&amp;quot; class=&amp;quot;danger&amp;quot;&amp;gt;Cancel all subs&amp;lt;/button&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div id=&amp;quot;active-subs&amp;quot; class=&amp;quot;muted&amp;quot; style=&amp;quot;margin-top: 8px; font-size: 0.78rem;&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;tip&amp;quot;&amp;gt;Filters are JSON predicates: &amp;lt;code&amp;gt;{kinds:[1], authors:[hex]}&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;{kinds:[4], &amp;quot;#p&amp;quot;:[my-hex]}&amp;lt;/code&amp;gt;, etc. Relays AND the conditions inside a filter, OR across multiple filters in one REQ.&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/section&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;section&amp;gt;&lt;br /&gt;
  &amp;lt;h2&amp;gt;5. Event Log — every WebSocket message in/out&amp;lt;/h2&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;help&amp;quot;&amp;gt;Click any JSON block to expand/collapse. Browser console (F12) gets the same data — useful for searching with Ctrl+F. Outgoing in &amp;lt;span style=&amp;quot;color:var(--accent)&amp;quot;&amp;gt;orange&amp;lt;/span&amp;gt;, incoming in &amp;lt;span style=&amp;quot;color:var(--blue)&amp;quot;&amp;gt;blue&amp;lt;/span&amp;gt;, system in grey, errors in red.&amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;row&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;button id=&amp;quot;clear-log&amp;quot;&amp;gt;Clear log&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;label style=&amp;quot;font-size:0.82rem;&amp;quot;&amp;gt;&amp;lt;input type=&amp;quot;checkbox&amp;quot; id=&amp;quot;show-raw&amp;quot; checked&amp;gt; Show raw JSON&amp;lt;/label&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div id=&amp;quot;event-log&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/section&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;script type=&amp;quot;module&amp;quot;&amp;gt;&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// Imports — pure browser ESM via esm.sh, no install, no build step.&lt;br /&gt;
// ============================================================================&lt;br /&gt;
//&lt;br /&gt;
// nostr-tools gives us: schnorr signing, schnorr verification, sha256 event-id,&lt;br /&gt;
// bech32 (npub/nsec) encode/decode, and NIP-04 ECDH+AES encryption.&lt;br /&gt;
// We deliberately use raw WebSockets (not the nostr-tools Relay class) so the&lt;br /&gt;
// REQ/EVENT/EOSE/OK/NOTICE/CLOSE wire dance is fully visible.&lt;br /&gt;
//&lt;br /&gt;
// Pinned to a specific 2.x version so this file keeps working when nostr-tools&lt;br /&gt;
// 3.x ships with breaking changes.&lt;br /&gt;
import {&lt;br /&gt;
  finalizeEvent,    // adds id + sig to an unsigned event&lt;br /&gt;
  getPublicKey,     // priv (Uint8Array) -&amp;gt; hex pubkey&lt;br /&gt;
  verifyEvent,      // validates id + sig&lt;br /&gt;
  nip04,            // .encrypt(priv, theirPub, plain) / .decrypt(priv, theirPub, cipher)&lt;br /&gt;
  nip19             // .npubEncode / .nsecEncode / .decode&lt;br /&gt;
} from &#039;https://esm.sh/nostr-tools@2.7.2&#039;;&lt;br /&gt;
&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// State&lt;br /&gt;
// ============================================================================&lt;br /&gt;
&lt;br /&gt;
const state = {&lt;br /&gt;
  privkey: null,        // Uint8Array(32) — null until a key is loaded&lt;br /&gt;
  pubkey: null,         // hex string (64 chars)&lt;br /&gt;
  npub: null,           // bech32 npub&lt;br /&gt;
  relays: new Map(),    // url -&amp;gt; { ws, status, msgs }&lt;br /&gt;
  subs:   new Map()     // subId -&amp;gt; { filters }&lt;br /&gt;
};&lt;br /&gt;
&lt;br /&gt;
const DEFAULT_RELAYS = [&lt;br /&gt;
  &#039;wss://nostr.hive-book.com&#039;,&lt;br /&gt;
  &#039;wss://nostr.v4call.com&#039;,&lt;br /&gt;
  &#039;wss://relay.damus.io&#039;,&lt;br /&gt;
  &#039;wss://nos.lol&#039;&lt;br /&gt;
];&lt;br /&gt;
&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// Tiny helpers&lt;br /&gt;
// ============================================================================&lt;br /&gt;
&lt;br /&gt;
function hexToBytes(hex) {&lt;br /&gt;
  if (hex.length % 2) throw new Error(&#039;hex string odd length&#039;);&lt;br /&gt;
  const out = new Uint8Array(hex.length / 2);&lt;br /&gt;
  for (let i = 0; i &amp;lt; out.length; i++) out[i] = parseInt(hex.slice(i*2, i*2+2), 16);&lt;br /&gt;
  return out;&lt;br /&gt;
}&lt;br /&gt;
function shortKey(hex)   { return hex ? hex.slice(0, 8) + &#039;…&#039; + hex.slice(-4) : &#039;&#039;; }&lt;br /&gt;
function shortNpub(npub) { return npub ? npub.slice(0, 12) + &#039;…&#039; + npub.slice(-4) : &#039;&#039;; }&lt;br /&gt;
function nowSec()        { return Math.floor(Date.now() / 1000); }&lt;br /&gt;
function timeStr() {&lt;br /&gt;
  const d = new Date();&lt;br /&gt;
  return d.toTimeString().slice(0,8) + &#039;.&#039; + String(d.getMilliseconds()).padStart(3,&#039;0&#039;);&lt;br /&gt;
}&lt;br /&gt;
function escapeHtml(s) {&lt;br /&gt;
  return String(s).replace(/[&amp;amp;&amp;lt;&amp;gt;&amp;quot;&#039;]/g, c =&amp;gt; ({&lt;br /&gt;
    &#039;&amp;amp;&#039;:&#039;&amp;amp;amp;&#039;,&#039;&amp;lt;&#039;:&#039;&amp;amp;lt;&#039;,&#039;&amp;gt;&#039;:&#039;&amp;amp;gt;&#039;,&#039;&amp;quot;&#039;:&#039;&amp;amp;quot;&#039;,&amp;quot;&#039;&amp;quot;:&#039;&amp;amp;#39;&#039;&lt;br /&gt;
  }[c]));&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// Logging — every WS in/out shows up here AND in console.log&lt;br /&gt;
// ============================================================================&lt;br /&gt;
&lt;br /&gt;
const logEl = document.getElementById(&#039;event-log&#039;);&lt;br /&gt;
&lt;br /&gt;
function addLog({ type, relay, summary, raw }) {&lt;br /&gt;
  const showRaw = document.getElementById(&#039;show-raw&#039;).checked;&lt;br /&gt;
  const div = document.createElement(&#039;div&#039;);&lt;br /&gt;
  div.className = &#039;log-entry &#039; + type;&lt;br /&gt;
  let html = `&amp;lt;div class=&amp;quot;head&amp;quot;&amp;gt;${timeStr()} `;&lt;br /&gt;
  if (relay)   html += `&amp;lt;span class=&amp;quot;relay-tag&amp;quot;&amp;gt;${escapeHtml(relay)}&amp;lt;/span&amp;gt; `;&lt;br /&gt;
  html += `&amp;lt;span class=&amp;quot;muted&amp;quot;&amp;gt;${type.toUpperCase()}&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;`;&lt;br /&gt;
  if (summary) html += `&amp;lt;div class=&amp;quot;summary&amp;quot;&amp;gt;${escapeHtml(summary)}&amp;lt;/div&amp;gt;`;&lt;br /&gt;
  if (raw &amp;amp;&amp;amp; showRaw) {&lt;br /&gt;
    let pretty;&lt;br /&gt;
    try {&lt;br /&gt;
      const obj = (typeof raw === &#039;string&#039;) ? JSON.parse(raw) : raw;&lt;br /&gt;
      pretty = JSON.stringify(obj, null, 2);&lt;br /&gt;
    } catch { pretty = String(raw); }&lt;br /&gt;
    html += `&amp;lt;pre class=&amp;quot;collapsed&amp;quot;&amp;gt;${escapeHtml(pretty)}&amp;lt;/pre&amp;gt;`;&lt;br /&gt;
  }&lt;br /&gt;
  div.innerHTML = html;&lt;br /&gt;
  logEl.prepend(div);&lt;br /&gt;
  const pre = div.querySelector(&#039;pre&#039;);&lt;br /&gt;
  if (pre) pre.addEventListener(&#039;click&#039;, () =&amp;gt; pre.classList.toggle(&#039;collapsed&#039;));&lt;br /&gt;
  // mirror to browser console for grep / Ctrl+F&lt;br /&gt;
  console.log(`[${type}]`, relay || &#039;-&#039;, summary || &#039;&#039;, raw ?? &#039;&#039;);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// Identity&lt;br /&gt;
// ============================================================================&lt;br /&gt;
&lt;br /&gt;
const idOut = document.getElementById(&#039;identity-out&#039;);&lt;br /&gt;
&lt;br /&gt;
document.getElementById(&#039;load-key&#039;).addEventListener(&#039;click&#039;, () =&amp;gt; {&lt;br /&gt;
  const raw = document.getElementById(&#039;nsec-input&#039;).value.trim();&lt;br /&gt;
  if (!raw) return;&lt;br /&gt;
  try {&lt;br /&gt;
    let priv;&lt;br /&gt;
    if (raw.startsWith(&#039;nsec1&#039;)) {&lt;br /&gt;
      const decoded = nip19.decode(raw);&lt;br /&gt;
      if (decoded.type !== &#039;nsec&#039;) throw new Error(&#039;not an nsec&#039;);&lt;br /&gt;
      priv = decoded.data;          // Uint8Array(32)&lt;br /&gt;
    } else if (/^[0-9a-f]{64}$/i.test(raw)) {&lt;br /&gt;
      priv = hexToBytes(raw.toLowerCase());&lt;br /&gt;
    } else {&lt;br /&gt;
      throw new Error(&#039;paste an nsec1... or 64-char hex&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    state.privkey = priv;&lt;br /&gt;
    state.pubkey  = getPublicKey(priv);&lt;br /&gt;
    state.npub    = nip19.npubEncode(state.pubkey);&lt;br /&gt;
    idOut.classList.remove(&#039;muted&#039;);&lt;br /&gt;
    idOut.innerHTML = `&lt;br /&gt;
      &amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;npub&amp;lt;/strong&amp;gt; ${escapeHtml(state.npub)}&amp;lt;/div&amp;gt;&lt;br /&gt;
      &amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;hex pubkey&amp;lt;/strong&amp;gt; ${escapeHtml(state.pubkey)}&amp;lt;/div&amp;gt;&lt;br /&gt;
    `;&lt;br /&gt;
    addLog({ type:&#039;system&#039;, summary:`Loaded identity ${shortNpub(state.npub)}`, raw:{ npub: state.npub, pubkey: state.pubkey } });&lt;br /&gt;
    document.getElementById(&#039;nsec-input&#039;).value = &#039;&#039;;&lt;br /&gt;
  } catch (e) {&lt;br /&gt;
    addLog({ type:&#039;error&#039;, summary:`Failed to load key: ${e.message}`, raw:null });&lt;br /&gt;
    alert(&#039;Bad key: &#039; + e.message);&lt;br /&gt;
  }&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
document.getElementById(&#039;forget-key&#039;).addEventListener(&#039;click&#039;, () =&amp;gt; {&lt;br /&gt;
  state.privkey = null;&lt;br /&gt;
  state.pubkey  = null;&lt;br /&gt;
  state.npub    = null;&lt;br /&gt;
  idOut.classList.add(&#039;muted&#039;);&lt;br /&gt;
  idOut.textContent = &#039;No key loaded.&#039;;&lt;br /&gt;
  addLog({ type:&#039;system&#039;, summary:&#039;Identity forgotten (privkey wiped from memory).&#039;, raw:null });&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// Relays&lt;br /&gt;
// ============================================================================&lt;br /&gt;
&lt;br /&gt;
const relayListEl = document.getElementById(&#039;relay-list&#039;);&lt;br /&gt;
&lt;br /&gt;
function renderRelayRow(url) {&lt;br /&gt;
  const r = state.relays.get(url) || { ws:null, status:&#039;disconnected&#039;, msgs:0 };&lt;br /&gt;
  let row = relayListEl.querySelector(`[data-url=&amp;quot;${CSS.escape(url)}&amp;quot;]`);&lt;br /&gt;
  if (!row) {&lt;br /&gt;
    row = document.createElement(&#039;div&#039;);&lt;br /&gt;
    row.className = &#039;relay-row&#039;;&lt;br /&gt;
    row.dataset.url = url;&lt;br /&gt;
    row.innerHTML = `&lt;br /&gt;
      &amp;lt;span class=&amp;quot;url&amp;quot;&amp;gt;${escapeHtml(url)}&amp;lt;/span&amp;gt;&lt;br /&gt;
      &amp;lt;span class=&amp;quot;status disconnected&amp;quot;&amp;gt;disconnected&amp;lt;/span&amp;gt;&lt;br /&gt;
      &amp;lt;span class=&amp;quot;msgs&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;&lt;br /&gt;
      &amp;lt;button class=&amp;quot;connect-btn&amp;quot;&amp;gt;Connect&amp;lt;/button&amp;gt;&lt;br /&gt;
      &amp;lt;button class=&amp;quot;disconnect-btn&amp;quot; style=&amp;quot;display:none;&amp;quot;&amp;gt;Disconnect&amp;lt;/button&amp;gt;&lt;br /&gt;
      &amp;lt;button class=&amp;quot;remove-btn&amp;quot; title=&amp;quot;Remove&amp;quot;&amp;gt;×&amp;lt;/button&amp;gt;&lt;br /&gt;
    `;&lt;br /&gt;
    relayListEl.appendChild(row);&lt;br /&gt;
    row.querySelector(&#039;.connect-btn&#039;).addEventListener(&#039;click&#039;, () =&amp;gt; connectRelay(url));&lt;br /&gt;
    row.querySelector(&#039;.disconnect-btn&#039;).addEventListener(&#039;click&#039;, () =&amp;gt; disconnectRelay(url));&lt;br /&gt;
    row.querySelector(&#039;.remove-btn&#039;).addEventListener(&#039;click&#039;, () =&amp;gt; {&lt;br /&gt;
      disconnectRelay(url);&lt;br /&gt;
      state.relays.delete(url);&lt;br /&gt;
      row.remove();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
  const statusEl = row.querySelector(&#039;.status&#039;);&lt;br /&gt;
  statusEl.className = &#039;status &#039; + r.status;&lt;br /&gt;
  statusEl.textContent = r.status;&lt;br /&gt;
  row.querySelector(&#039;.msgs&#039;).textContent = r.msgs ? `${r.msgs} msgs` : &#039;&#039;;&lt;br /&gt;
  const open = (r.status === &#039;open&#039; || r.status === &#039;connecting&#039;);&lt;br /&gt;
  row.querySelector(&#039;.connect-btn&#039;).style.display    = open ? &#039;none&#039; : &#039;&#039;;&lt;br /&gt;
  row.querySelector(&#039;.disconnect-btn&#039;).style.display = open ? &#039;&#039; : &#039;none&#039;;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
function connectRelay(url) {&lt;br /&gt;
  let r = state.relays.get(url);&lt;br /&gt;
  if (!r) { r = { ws:null, status:&#039;disconnected&#039;, msgs:0 }; state.relays.set(url, r); }&lt;br /&gt;
  if (r.ws &amp;amp;&amp;amp; (r.ws.readyState === WebSocket.CONNECTING || r.ws.readyState === WebSocket.OPEN)) return;&lt;br /&gt;
  r.status = &#039;connecting&#039;;&lt;br /&gt;
  renderRelayRow(url);&lt;br /&gt;
  addLog({ type:&#039;system&#039;, relay:url, summary:&#039;Connecting…&#039;, raw:null });&lt;br /&gt;
  let ws;&lt;br /&gt;
  try {&lt;br /&gt;
    ws = new WebSocket(url);&lt;br /&gt;
  } catch (e) {&lt;br /&gt;
    r.status = &#039;error&#039;;&lt;br /&gt;
    addLog({ type:&#039;error&#039;, relay:url, summary:e.message, raw:null });&lt;br /&gt;
    renderRelayRow(url);&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
  r.ws = ws;&lt;br /&gt;
  ws.addEventListener(&#039;open&#039;, () =&amp;gt; {&lt;br /&gt;
    r.status = &#039;open&#039;;&lt;br /&gt;
    addLog({ type:&#039;system&#039;, relay:url, summary:&#039;WebSocket open.&#039;, raw:null });&lt;br /&gt;
    renderRelayRow(url);&lt;br /&gt;
    // Re-attach existing subs to the newly opened relay&lt;br /&gt;
    for (const [subId, sub] of state.subs) {&lt;br /&gt;
      const msg = [&#039;REQ&#039;, subId, ...sub.filters];&lt;br /&gt;
      ws.send(JSON.stringify(msg));&lt;br /&gt;
      addLog({ type:&#039;out&#039;, relay:url, summary:`REQ ${subId} (re-attached)`, raw:msg });&lt;br /&gt;
    }&lt;br /&gt;
  });&lt;br /&gt;
  ws.addEventListener(&#039;message&#039;, (ev) =&amp;gt; {&lt;br /&gt;
    r.msgs++;&lt;br /&gt;
    renderRelayRow(url);&lt;br /&gt;
    let parsed;&lt;br /&gt;
    try { parsed = JSON.parse(ev.data); }&lt;br /&gt;
    catch { addLog({ type:&#039;in&#039;, relay:url, summary:&#039;non-JSON message&#039;, raw:ev.data }); return; }&lt;br /&gt;
    handleRelayMessage(url, parsed);&lt;br /&gt;
  });&lt;br /&gt;
  ws.addEventListener(&#039;close&#039;, () =&amp;gt; {&lt;br /&gt;
    r.status = &#039;disconnected&#039;;&lt;br /&gt;
    addLog({ type:&#039;system&#039;, relay:url, summary:&#039;WebSocket closed.&#039;, raw:null });&lt;br /&gt;
    renderRelayRow(url);&lt;br /&gt;
  });&lt;br /&gt;
  ws.addEventListener(&#039;error&#039;, () =&amp;gt; {&lt;br /&gt;
    r.status = &#039;error&#039;;&lt;br /&gt;
    addLog({ type:&#039;error&#039;, relay:url, summary:&#039;WebSocket error (DNS / TLS / blocked port?)&#039;, raw:null });&lt;br /&gt;
    renderRelayRow(url);&lt;br /&gt;
  });&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
function disconnectRelay(url) {&lt;br /&gt;
  const r = state.relays.get(url);&lt;br /&gt;
  if (r &amp;amp;&amp;amp; r.ws) { try { r.ws.close(); } catch {} }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
function handleRelayMessage(url, msg) {&lt;br /&gt;
  if (!Array.isArray(msg)) {&lt;br /&gt;
    addLog({ type:&#039;in&#039;, relay:url, summary:&#039;unknown shape&#039;, raw:msg });&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
  const verb = msg[0];&lt;br /&gt;
  if (verb === &#039;EVENT&#039;) {&lt;br /&gt;
    const [, subId, ev] = msg;&lt;br /&gt;
    let summary = `EVENT sub=${subId} kind=${ev.kind} from=${shortKey(ev.pubkey)}`;&lt;br /&gt;
    // Auto-decrypt kind 4 if I can&lt;br /&gt;
    if (ev.kind === 4 &amp;amp;&amp;amp; state.privkey) {&lt;br /&gt;
      const myHex = state.pubkey;&lt;br /&gt;
      const iAmRecipient = ev.tags.some(t =&amp;gt; t[0] === &#039;p&#039; &amp;amp;&amp;amp; t[1] === myHex);&lt;br /&gt;
      const iAmSender    = ev.pubkey === myHex;&lt;br /&gt;
      if (iAmRecipient || iAmSender) {&lt;br /&gt;
        const counterparty = iAmRecipient ? ev.pubkey : (ev.tags.find(t =&amp;gt; t[0] === &#039;p&#039;) || [])[1];&lt;br /&gt;
        if (counterparty) {&lt;br /&gt;
          nip04.decrypt(state.privkey, counterparty, ev.content)&lt;br /&gt;
            .then(plain =&amp;gt; {&lt;br /&gt;
              const tag = iAmRecipient ? `from ${shortKey(ev.pubkey)}` : `to ${shortKey(counterparty)}`;&lt;br /&gt;
              addLog({ type:&#039;in&#039;, relay:url, summary:`DM (decrypted) ${tag}: ${plain}`, raw:ev });&lt;br /&gt;
            })&lt;br /&gt;
            .catch(e =&amp;gt; addLog({ type:&#039;in&#039;, relay:url, summary:`${summary} — decrypt failed: ${e.message}`, raw:ev }));&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
    if (ev.kind === 1) summary += ` content=&amp;quot;${(ev.content || &#039;&#039;).slice(0, 80).replace(/\n/g,&#039; &#039;)}&amp;quot;`;&lt;br /&gt;
    addLog({ type:&#039;in&#039;, relay:url, summary, raw:ev });&lt;br /&gt;
  } else if (verb === &#039;EOSE&#039;) {&lt;br /&gt;
    addLog({ type:&#039;in&#039;, relay:url, summary:`EOSE sub=${msg[1]} (end of stored events; live updates continue)`, raw:msg });&lt;br /&gt;
  } else if (verb === &#039;OK&#039;) {&lt;br /&gt;
    const [, eventId, accepted, reason] = msg;&lt;br /&gt;
    addLog({ type:&#039;in&#039;, relay:url, summary:`OK ${shortKey(eventId)} accepted=${accepted}${reason ? &#039; reason=&amp;quot;&#039;+reason+&#039;&amp;quot;&#039; : &#039;&#039;}`, raw:msg });&lt;br /&gt;
  } else if (verb === &#039;NOTICE&#039;) {&lt;br /&gt;
    addLog({ type:&#039;in&#039;, relay:url, summary:`NOTICE: ${msg[1]}`, raw:msg });&lt;br /&gt;
  } else if (verb === &#039;CLOSED&#039;) {&lt;br /&gt;
    addLog({ type:&#039;in&#039;, relay:url, summary:`CLOSED sub=${msg[1]}${msg[2] ? &#039; reason=&amp;quot;&#039;+msg[2]+&#039;&amp;quot;&#039; : &#039;&#039;}`, raw:msg });&lt;br /&gt;
  } else {&lt;br /&gt;
    addLog({ type:&#039;in&#039;, relay:url, summary:`unknown verb ${verb}`, raw:msg });&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
document.getElementById(&#039;add-relay&#039;).addEventListener(&#039;click&#039;, () =&amp;gt; {&lt;br /&gt;
  const url = document.getElementById(&#039;new-relay&#039;).value.trim();&lt;br /&gt;
  if (!url) return;&lt;br /&gt;
  if (!/^wss?:\/\//.test(url)) { alert(&#039;URL must start with wss:// or ws://&#039;); return; }&lt;br /&gt;
  if (!state.relays.has(url)) state.relays.set(url, { ws:null, status:&#039;disconnected&#039;, msgs:0 });&lt;br /&gt;
  renderRelayRow(url);&lt;br /&gt;
  document.getElementById(&#039;new-relay&#039;).value = &#039;&#039;;&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
DEFAULT_RELAYS.forEach(url =&amp;gt; {&lt;br /&gt;
  state.relays.set(url, { ws:null, status:&#039;disconnected&#039;, msgs:0 });&lt;br /&gt;
  renderRelayRow(url);&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// Tabs&lt;br /&gt;
// ============================================================================&lt;br /&gt;
&lt;br /&gt;
document.querySelectorAll(&#039;.tab-btn&#039;).forEach(btn =&amp;gt; {&lt;br /&gt;
  btn.addEventListener(&#039;click&#039;, () =&amp;gt; {&lt;br /&gt;
    document.querySelectorAll(&#039;.tab-btn&#039;).forEach(b =&amp;gt; b.classList.remove(&#039;active&#039;));&lt;br /&gt;
    document.querySelectorAll(&#039;.tab-pane&#039;).forEach(p =&amp;gt; p.classList.remove(&#039;active&#039;));&lt;br /&gt;
    btn.classList.add(&#039;active&#039;);&lt;br /&gt;
    document.querySelector(`[data-pane=&amp;quot;${btn.dataset.tab}&amp;quot;]`).classList.add(&#039;active&#039;);&lt;br /&gt;
  });&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// Compose: public note (kind 1)&lt;br /&gt;
// ============================================================================&lt;br /&gt;
&lt;br /&gt;
document.getElementById(&#039;send-note&#039;).addEventListener(&#039;click&#039;, () =&amp;gt; {&lt;br /&gt;
  if (!state.privkey) return alert(&#039;Load your key in section 1 first.&#039;);&lt;br /&gt;
  const content = document.getElementById(&#039;note-content&#039;).value;&lt;br /&gt;
  if (!content.trim()) return alert(&#039;Note is empty.&#039;);&lt;br /&gt;
&lt;br /&gt;
  // Build the unsigned event. Per NIP-01, an event has these fields:&lt;br /&gt;
  //   pubkey, created_at, kind, tags, content&lt;br /&gt;
  // (id and sig are added by finalizeEvent.)&lt;br /&gt;
  const unsigned = {&lt;br /&gt;
    kind: 1,&lt;br /&gt;
    created_at: nowSec(),&lt;br /&gt;
    tags: [],&lt;br /&gt;
    content&lt;br /&gt;
  };&lt;br /&gt;
  // finalizeEvent does:&lt;br /&gt;
  //   1. set pubkey from priv&lt;br /&gt;
  //   2. compute id = sha256( JSON([0, pubkey, created_at, kind, tags, content]) )&lt;br /&gt;
  //   3. sign id with schnorr (BIP-340) using priv&lt;br /&gt;
  //   4. attach id + sig to the event&lt;br /&gt;
  const signed = finalizeEvent(unsigned, state.privkey);&lt;br /&gt;
  if (!verifyEvent(signed)) {&lt;br /&gt;
    addLog({ type:&#039;error&#039;, summary:&#039;verifyEvent failed — refusing to publish (this should never happen)&#039;, raw:signed });&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
  publishEvent(signed);&lt;br /&gt;
  document.getElementById(&#039;note-content&#039;).value = &#039;&#039;;&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// Compose: DM (kind 4 NIP-04)&lt;br /&gt;
// ============================================================================&lt;br /&gt;
&lt;br /&gt;
document.getElementById(&#039;send-dm&#039;).addEventListener(&#039;click&#039;, async () =&amp;gt; {&lt;br /&gt;
  if (!state.privkey) return alert(&#039;Load your key in section 1 first.&#039;);&lt;br /&gt;
  const recipRaw = document.getElementById(&#039;dm-recipient&#039;).value.trim();&lt;br /&gt;
  const body     = document.getElementById(&#039;dm-content&#039;).value;&lt;br /&gt;
  if (!recipRaw || !body.trim()) return alert(&#039;Need both recipient and message.&#039;);&lt;br /&gt;
&lt;br /&gt;
  let recipHex;&lt;br /&gt;
  try {&lt;br /&gt;
    if (recipRaw.startsWith(&#039;npub1&#039;)) {&lt;br /&gt;
      const dec = nip19.decode(recipRaw);&lt;br /&gt;
      if (dec.type !== &#039;npub&#039;) throw new Error(&#039;not an npub&#039;);&lt;br /&gt;
      recipHex = dec.data;&lt;br /&gt;
    } else if (/^[0-9a-f]{64}$/i.test(recipRaw)) {&lt;br /&gt;
      recipHex = recipRaw.toLowerCase();&lt;br /&gt;
    } else throw new Error(&#039;paste an npub1... or hex pubkey&#039;);&lt;br /&gt;
  } catch (e) {&lt;br /&gt;
    return alert(&#039;Bad recipient: &#039; + e.message);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  // NIP-04: ECDH(my-priv, their-pub) -&amp;gt; shared secret -&amp;gt; AES-CBC encrypt(body)&lt;br /&gt;
  // -&amp;gt; content = base64(ciphertext) + &amp;quot;?iv=&amp;quot; + base64(iv)&lt;br /&gt;
  let ciphertext;&lt;br /&gt;
  try {&lt;br /&gt;
    ciphertext = await nip04.encrypt(state.privkey, recipHex, body);&lt;br /&gt;
  } catch (e) {&lt;br /&gt;
    addLog({ type:&#039;error&#039;, summary:`NIP-04 encrypt failed: ${e.message}`, raw:{ recipient: recipHex, error: e.message } });&lt;br /&gt;
    alert(&#039;Encrypt failed: &#039; + e.message + &#039;\n\nUsually means a bad recipient pubkey.&#039;);&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  const unsigned = {&lt;br /&gt;
    kind: 4,&lt;br /&gt;
    created_at: nowSec(),&lt;br /&gt;
    tags: [[&#039;p&#039;, recipHex]],   // &#039;p&#039; tag tells relays who the recipient is (visible plaintext!)&lt;br /&gt;
    content: ciphertext&lt;br /&gt;
  };&lt;br /&gt;
  let signed;&lt;br /&gt;
  try {&lt;br /&gt;
    signed = finalizeEvent(unsigned, state.privkey);&lt;br /&gt;
  } catch (e) {&lt;br /&gt;
    addLog({ type:&#039;error&#039;, summary:`finalizeEvent failed: ${e.message}`, raw:unsigned });&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
  if (!verifyEvent(signed)) {&lt;br /&gt;
    addLog({ type:&#039;error&#039;, summary:&#039;verifyEvent failed — refusing to publish&#039;, raw:signed });&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
  publishEvent(signed);&lt;br /&gt;
  document.getElementById(&#039;dm-content&#039;).value = &#039;&#039;;&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
function publishEvent(signed) {&lt;br /&gt;
  const msg = [&#039;EVENT&#039;, signed];&lt;br /&gt;
  let sent = 0;&lt;br /&gt;
  for (const [url, r] of state.relays) {&lt;br /&gt;
    if (r.ws &amp;amp;&amp;amp; r.ws.readyState === WebSocket.OPEN) {&lt;br /&gt;
      r.ws.send(JSON.stringify(msg));&lt;br /&gt;
      sent++;&lt;br /&gt;
      addLog({ type:&#039;out&#039;, relay:url, summary:`EVENT kind=${signed.kind} id=${shortKey(signed.id)}`, raw:msg });&lt;br /&gt;
    }&lt;br /&gt;
  }&lt;br /&gt;
  if (!sent) addLog({ type:&#039;error&#039;, summary:&#039;No open relays — nothing was published. Connect a relay in section 2.&#039;, raw:signed });&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// Subscribe presets&lt;br /&gt;
// ============================================================================&lt;br /&gt;
&lt;br /&gt;
let subCounter = 0;&lt;br /&gt;
function nextSubId() { return `sub-${++subCounter}`; }&lt;br /&gt;
&lt;br /&gt;
function publishSub(filters) {&lt;br /&gt;
  const subId = nextSubId();&lt;br /&gt;
  state.subs.set(subId, { filters });&lt;br /&gt;
  const msg = [&#039;REQ&#039;, subId, ...filters];&lt;br /&gt;
  let sent = 0;&lt;br /&gt;
  for (const [url, r] of state.relays) {&lt;br /&gt;
    if (r.ws &amp;amp;&amp;amp; r.ws.readyState === WebSocket.OPEN) {&lt;br /&gt;
      r.ws.send(JSON.stringify(msg));&lt;br /&gt;
      addLog({ type:&#039;out&#039;, relay:url, summary:`REQ ${subId}`, raw:msg });&lt;br /&gt;
      sent++;&lt;br /&gt;
    }&lt;br /&gt;
  }&lt;br /&gt;
  if (!sent) addLog({ type:&#039;error&#039;, summary:&#039;No open relays — REQ not sent.&#039;, raw:msg });&lt;br /&gt;
  renderActiveSubs();&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
document.querySelectorAll(&#039;.sub-preset&#039;).forEach(btn =&amp;gt; {&lt;br /&gt;
  btn.addEventListener(&#039;click&#039;, () =&amp;gt; {&lt;br /&gt;
    const preset = btn.dataset.preset;&lt;br /&gt;
    if (preset === &#039;my-events&#039;) {&lt;br /&gt;
      if (!state.pubkey) return alert(&#039;Load key first.&#039;);&lt;br /&gt;
      publishSub([{ kinds:[0,1,3,4,7], authors:[state.pubkey], limit:50 }]);&lt;br /&gt;
    } else if (preset === &#039;my-dms&#039;) {&lt;br /&gt;
      if (!state.pubkey) return alert(&#039;Load key first.&#039;);&lt;br /&gt;
      publishSub([{ kinds:[4], &#039;#p&#039;:[state.pubkey], limit:50 }]);&lt;br /&gt;
    } else if (preset === &#039;lookup-author&#039;) {&lt;br /&gt;
      const raw = prompt(&#039;Author npub or 64-char hex:&#039;);&lt;br /&gt;
      if (!raw) return;&lt;br /&gt;
      let hex;&lt;br /&gt;
      try {&lt;br /&gt;
        if (raw.startsWith(&#039;npub1&#039;)) {&lt;br /&gt;
          const d = nip19.decode(raw.trim());&lt;br /&gt;
          if (d.type !== &#039;npub&#039;) throw new Error(&#039;not an npub&#039;);&lt;br /&gt;
          hex = d.data;&lt;br /&gt;
        } else if (/^[0-9a-f]{64}$/i.test(raw.trim())) {&lt;br /&gt;
          hex = raw.trim().toLowerCase();&lt;br /&gt;
        } else throw new Error(&#039;bad input&#039;);&lt;br /&gt;
      } catch (e) { return alert(e.message); }&lt;br /&gt;
      publishSub([{ kinds:[0,1], authors:[hex], limit:30 }]);&lt;br /&gt;
    } else if (preset === &#039;firehose&#039;) {&lt;br /&gt;
      publishSub([{ kinds:[1], limit:20 }]);&lt;br /&gt;
    }&lt;br /&gt;
  });&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
document.getElementById(&#039;cancel-subs&#039;).addEventListener(&#039;click&#039;, () =&amp;gt; {&lt;br /&gt;
  for (const [subId] of state.subs) {&lt;br /&gt;
    const msg = [&#039;CLOSE&#039;, subId];&lt;br /&gt;
    for (const [url, r] of state.relays) {&lt;br /&gt;
      if (r.ws &amp;amp;&amp;amp; r.ws.readyState === WebSocket.OPEN) {&lt;br /&gt;
        r.ws.send(JSON.stringify(msg));&lt;br /&gt;
        addLog({ type:&#039;out&#039;, relay:url, summary:`CLOSE ${subId}`, raw:msg });&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
  }&lt;br /&gt;
  state.subs.clear();&lt;br /&gt;
  renderActiveSubs();&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
function renderActiveSubs() {&lt;br /&gt;
  const el = document.getElementById(&#039;active-subs&#039;);&lt;br /&gt;
  if (!state.subs.size) { el.textContent = &#039;No active subscriptions.&#039;; return; }&lt;br /&gt;
  el.textContent = `Active subs: ${Array.from(state.subs.keys()).join(&#039;, &#039;)}`;&lt;br /&gt;
}&lt;br /&gt;
renderActiveSubs();&lt;br /&gt;
&lt;br /&gt;
// ============================================================================&lt;br /&gt;
// Misc&lt;br /&gt;
// ============================================================================&lt;br /&gt;
&lt;br /&gt;
document.getElementById(&#039;clear-log&#039;).addEventListener(&#039;click&#039;, () =&amp;gt; { logEl.innerHTML = &#039;&#039;; });&lt;br /&gt;
&lt;br /&gt;
addLog({ type:&#039;system&#039;, summary:&#039;Page loaded. Open the browser console (F12) to see all WebSocket traffic mirrored there.&#039;, raw:null });&lt;br /&gt;
&amp;lt;/script&amp;gt;&lt;br /&gt;
&amp;lt;/body&amp;gt;&lt;br /&gt;
&amp;lt;/html&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Why a hands-on page beats a real client for learning ==&lt;br /&gt;
&lt;br /&gt;
* Real clients pick relays for you, sign for you, decrypt for you, render for you. Magic = mystery = doesn&#039;t stick.&lt;br /&gt;
* This page does &#039;&#039;nothing&#039;&#039; automatically. You connect each relay manually. You build each event manually. You pick filters manually. You see every signed JSON object before it leaves and after it arrives.&lt;br /&gt;
* The whole thing is one HTML file with all logic in plain ES modules. &amp;lt;code&amp;gt;view-source:&amp;lt;/code&amp;gt; in the browser &#039;&#039;is&#039;&#039; the tutorial. Read the comments inline as you click through the lessons.&lt;br /&gt;
* When you &#039;&#039;do&#039;&#039; graduate to a real client, you&#039;ll know exactly what each setting in &amp;quot;Relays&amp;quot; / &amp;quot;DM&amp;quot; / &amp;quot;Notifications&amp;quot; maps to in the protocol.&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
&lt;br /&gt;
* [[#Prerequisites|1 Prerequisites]]&lt;br /&gt;
* [[#Step_0:_Open_the_page|2 Step 0: Open the page (no install)]]&lt;br /&gt;
* [[#Lesson_1:_Load_a_key|3 Lesson 1: Load a key]]&lt;br /&gt;
* [[#Lesson_2:_Connect_a_relay|4 Lesson 2: Connect a relay]]&lt;br /&gt;
* [[#Lesson_3:_Publish_your_first_public_note|5 Lesson 3: Publish your first public note]]&lt;br /&gt;
* [[#Lesson_4:_Verify_on_the_server_with_nak|6 Lesson 4: Verify on the server with nak]]&lt;br /&gt;
* [[#Lesson_5:_Subscribe_to_your_own_feed|7 Lesson 5: Subscribe to your own feed]]&lt;br /&gt;
* [[#Lesson_6:_Open_a_second_identity_in_a_second_window|8 Lesson 6: Open a second identity in a second window]]&lt;br /&gt;
* [[#Lesson_7:_Have_a_public-note_conversation|9 Lesson 7: Have a public-note conversation]]&lt;br /&gt;
* [[#Lesson_8:_Send_an_encrypted_DM|10 Lesson 8: Send an encrypted DM (NIP-04)]]&lt;br /&gt;
* [[#Lesson_9:_Read_the_raw_JSON|11 Lesson 9: Read the raw JSON]]&lt;br /&gt;
* [[#Lesson_10:_Watch_the_firehose|12 Lesson 10: Watch the firehose (a public relay)]]&lt;br /&gt;
* [[#Lesson_11:_Server-side_view|13 Lesson 11: Server-side view (Docker logs + nak)]]&lt;br /&gt;
* [[#Lesson_12:_Break_things_on_purpose|14 Lesson 12: Break things on purpose]]&lt;br /&gt;
* [[#What_this_page_does_NOT_do|15 What this page does NOT do]]&lt;br /&gt;
* [[#Wisdom_for_the_Nostr_Noob|16 Wisdom for the Nostr Noob]]&lt;br /&gt;
* [[#Where_to_go_after_this|17 Where to go after this]]&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Two relays from stage 1 working&#039;&#039;&#039; — &amp;lt;code&amp;gt;wss://nostr.hive-book.com&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;wss://nostr.v4call.com&amp;lt;/code&amp;gt; (or your equivalents).&lt;br /&gt;
* &#039;&#039;&#039;Three nsec keys&#039;&#039;&#039; generated and whitelisted on both relays. Stage 1 covered this. The keys can be from &amp;lt;code&amp;gt;nostr-gen.html&amp;lt;/code&amp;gt;, your normal Nostr client, or &amp;lt;code&amp;gt;nak key generate&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;nak installed on your laptop&#039;&#039;&#039; (Stage 1 → Step 14 → Option 1C). Used in lessons 4 + 11.&lt;br /&gt;
* &#039;&#039;&#039;A modern browser with developer tools&#039;&#039;&#039; — Brave, Firefox, Chrome, Safari all work. We&#039;ll reference Brave in screenshots since that&#039;s CompleteNoobs&#039; default.&lt;br /&gt;
* &#039;&#039;&#039;nostr-handson.html&#039;&#039;&#039; — sitting in this folder next to this wiki.&lt;br /&gt;
&lt;br /&gt;
== Step 0: Open the page (no install) ==&lt;br /&gt;
&lt;br /&gt;
Three ways to load &amp;lt;code&amp;gt;nostr-handson.html&amp;lt;/code&amp;gt;; pick whichever you prefer:&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Option A — file:// (simplest)&#039;&#039;&#039;&lt;br /&gt;
:Just double-click the file in your file manager, or drag it into a browser tab. Address bar will show &amp;lt;code&amp;gt;file:///path/to/nostr-handson.html&amp;lt;/code&amp;gt;. WebSocket-to-internet works fine from &amp;lt;code&amp;gt;file://&amp;lt;/code&amp;gt;; no special server needed.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Option B — &amp;lt;code&amp;gt;python3 -m http.server&amp;lt;/code&amp;gt; (if you prefer http://)&#039;&#039;&#039;&lt;br /&gt;
:From the folder containing the file:&lt;br /&gt;
: &amp;lt;code&amp;gt;python3 -m http.server 8000&amp;lt;/code&amp;gt;&lt;br /&gt;
:Then browse to &amp;lt;code&amp;gt;http://localhost:8000/nostr-handson.html&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Option C — Host it on your relay box&#039;&#039;&#039; (clean URL, accessible from any device)&lt;br /&gt;
:Drop the file into your relay&#039;s web root and serve via Caddy. Out of scope for this lesson; do it later if you want.&lt;br /&gt;
&lt;br /&gt;
The page imports its crypto + bech32 helpers from &amp;lt;code&amp;gt;esm.sh&amp;lt;/code&amp;gt; on first load — that&#039;s a CDN fetch of &amp;lt;code&amp;gt;nostr-tools&amp;lt;/code&amp;gt;. After that the page is fully functional even offline (until cache expires). &#039;&#039;&#039;Open the browser console (F12) before you click anything.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Lesson 1: Load a key ==&lt;br /&gt;
&lt;br /&gt;
# In Section 1, paste an &amp;lt;code&amp;gt;nsec1...&amp;lt;/code&amp;gt; from one of your three whitelisted keys. Use a &#039;&#039;&#039;disposable / learning&#039;&#039;&#039; key — see the warning in the page.&lt;br /&gt;
# Click &amp;lt;strong&amp;gt;Load Key&amp;lt;/strong&amp;gt;.&lt;br /&gt;
# The page derives + displays your &amp;lt;code&amp;gt;npub&amp;lt;/code&amp;gt; and your hex pubkey.&lt;br /&gt;
# Open your browser console — there&#039;s a system log line: &amp;lt;code&amp;gt;[system] - Loaded identity npub1abc…xyz4 {npub: ..., pubkey: ...}&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;What just happened&#039;&#039;&#039;&lt;br /&gt;
* &amp;lt;code&amp;gt;nip19.decode(&amp;quot;nsec1...&amp;quot;)&amp;lt;/code&amp;gt; turned your bech32 nsec into raw bytes.&lt;br /&gt;
* &amp;lt;code&amp;gt;getPublicKey(privBytes)&amp;lt;/code&amp;gt; ran a single secp256k1 scalar multiplication to derive your pubkey. Same math underlies BTC, ETH, Hive — Nostr just uses the result differently.&lt;br /&gt;
* &amp;lt;code&amp;gt;nip19.npubEncode(pubkeyHex)&amp;lt;/code&amp;gt; wrapped the pubkey in bech32 to get your &amp;lt;code&amp;gt;npub&amp;lt;/code&amp;gt;.&lt;br /&gt;
* The privkey is now sitting in &amp;lt;code&amp;gt;state.privkey&amp;lt;/code&amp;gt; in browser memory. Closing the tab forgets it.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;An npub and a hex pubkey are the same key, just two encodings.&#039;&#039;&#039; Relays use hex internally. Clients display npub. Always 64 hex chars / always starts &amp;lt;code&amp;gt;npub1&amp;lt;/code&amp;gt;. If you only see one form, you can convert any time with &amp;lt;code&amp;gt;nak decode npub1...&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Lesson 2: Connect a relay ==&lt;br /&gt;
&lt;br /&gt;
# In Section 2 you&#039;ll see four pre-filled relays — your two privates plus &amp;lt;code&amp;gt;relay.damus.io&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;nos.lol&amp;lt;/code&amp;gt; (popular publics).&lt;br /&gt;
# Click &amp;lt;strong&amp;gt;Connect&amp;lt;/strong&amp;gt; on &amp;lt;code&amp;gt;wss://nostr.hive-book.com&amp;lt;/code&amp;gt; (or whichever of yours).&lt;br /&gt;
# Status flips: &amp;lt;code&amp;gt;disconnected&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;connecting&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;open&amp;lt;/code&amp;gt; (green).&lt;br /&gt;
# Console gets one &amp;lt;code&amp;gt;[system]&amp;lt;/code&amp;gt; entry per state change.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;What just happened&#039;&#039;&#039;&lt;br /&gt;
* &amp;lt;code&amp;gt;new WebSocket(&amp;quot;wss://nostr.hive-book.com&amp;quot;)&amp;lt;/code&amp;gt; opened a TCP/TLS/WebSocket-upgrade chain to the relay.&lt;br /&gt;
* The browser dev tools&#039; &amp;lt;strong&amp;gt;Network&amp;lt;/strong&amp;gt; tab → filter &amp;lt;strong&amp;gt;WS&amp;lt;/strong&amp;gt; shows the connection. Click it → &amp;lt;strong&amp;gt;Messages&amp;lt;/strong&amp;gt; sub-tab shows every frame in/out. Useful as a second view alongside section 5.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;A Nostr connection is just a WebSocket.&#039;&#039;&#039; Same plumbing as a chat room, a stock ticker, or a multiplayer game. Nothing magical at the transport layer. The protocol on top of it (REQ / EVENT / EOSE / OK / NOTICE / CLOSED) is what&#039;s &amp;quot;Nostr-y&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
== Lesson 3: Publish your first public note ==&lt;br /&gt;
&lt;br /&gt;
# Section 3 → &amp;quot;Public Note (kind 1)&amp;quot; tab is selected by default.&lt;br /&gt;
# Type something memorable: &amp;lt;code&amp;gt;hello world from nostr-handson&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Click &amp;lt;strong&amp;gt;Sign &amp;amp;amp; Publish&amp;lt;/strong&amp;gt;.&lt;br /&gt;
# Watch sections 5 + the console:&lt;br /&gt;
:* &amp;lt;code&amp;gt;[out] EVENT kind=1 id=abc123…&amp;lt;/code&amp;gt; with the full JSON.&lt;br /&gt;
:* Then a &amp;lt;code&amp;gt;[in] OK abc123… accepted=true&amp;lt;/code&amp;gt; from each connected relay.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;What just happened — the most important JSON in Nostr&#039;&#039;&#039;&lt;br /&gt;
:Click the JSON block in the log to expand. You&#039;ll see something like:&lt;br /&gt;
 {&lt;br /&gt;
   &amp;quot;kind&amp;quot;: 1,&lt;br /&gt;
   &amp;quot;created_at&amp;quot;: 1746554000,&lt;br /&gt;
   &amp;quot;tags&amp;quot;: [],&lt;br /&gt;
   &amp;quot;content&amp;quot;: &amp;quot;hello world from nostr-handson&amp;quot;,&lt;br /&gt;
   &amp;quot;pubkey&amp;quot;: &amp;quot;abc123…&amp;quot;,      // hex pubkey, derived from your nsec&lt;br /&gt;
   &amp;quot;id&amp;quot;:     &amp;quot;deadbeef…&amp;quot;,   // sha256 of [0,pubkey,created_at,kind,tags,content]&lt;br /&gt;
   &amp;quot;sig&amp;quot;:    &amp;quot;f00ba12…&amp;quot;     // schnorr signature of id, by your privkey&lt;br /&gt;
 }&lt;br /&gt;
:&#039;&#039;&#039;The id and sig are the whole proof.&#039;&#039;&#039; Anyone in the world holding only this JSON can verify it actually came from your pubkey and hasn&#039;t been tampered with — without ever talking to you, the relay, or anything.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;A Nostr event is just signed JSON.&#039;&#039;&#039; That&#039;s it. Every &amp;quot;post&amp;quot; / &amp;quot;DM&amp;quot; / &amp;quot;reaction&amp;quot; / &amp;quot;follow list&amp;quot; / &amp;quot;long-form article&amp;quot; / &amp;quot;zap receipt&amp;quot; / etc. is the same shape: a JSON object with &amp;lt;code&amp;gt;kind&amp;lt;/code&amp;gt; picking the type, &amp;lt;code&amp;gt;tags&amp;lt;/code&amp;gt; attaching metadata, and &amp;lt;code&amp;gt;content&amp;lt;/code&amp;gt; being the body. Hundreds of kinds defined across NIPs, all the same fundamental wrapper.&lt;br /&gt;
&lt;br /&gt;
== Lesson 4: Verify on the server with nak ==&lt;br /&gt;
&lt;br /&gt;
You don&#039;t have to trust the page — go look at the relay directly.&lt;br /&gt;
&lt;br /&gt;
 nak req -k 1 -a YOUR_HEX_PUBKEY wss://nostr.hive-book.com&lt;br /&gt;
&lt;br /&gt;
(Use the hex pubkey from section 1 of the page, not the npub.)&lt;br /&gt;
&lt;br /&gt;
You should see your note printed. nak hits the WebSocket, sends a REQ with &amp;lt;code&amp;gt;kinds:[1], authors:[your-hex]&amp;lt;/code&amp;gt;, prints every EVENT, then exits on EOSE.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;Whenever a client claims something is missing, query the relay directly.&#039;&#039;&#039; If nak finds the event but your client doesn&#039;t show it, the bug is in the client&#039;s relay config or filter, not in your publish. This single habit will save you days of debugging later.&lt;br /&gt;
&lt;br /&gt;
== Lesson 5: Subscribe to your own feed ==&lt;br /&gt;
&lt;br /&gt;
# Section 4 → click &amp;lt;strong&amp;gt;My events&amp;lt;/strong&amp;gt;.&lt;br /&gt;
# Watch the log:&lt;br /&gt;
:* &amp;lt;code&amp;gt;[out] REQ sub-1&amp;lt;/code&amp;gt; with filter &amp;lt;code&amp;gt;{kinds:[0,1,3,4,7], authors:[your-hex], limit:50}&amp;lt;/code&amp;gt;.&lt;br /&gt;
:* &amp;lt;code&amp;gt;[in] EVENT sub=sub-1 kind=1 ...&amp;lt;/code&amp;gt; — your note from lesson 3 comes back.&lt;br /&gt;
:* &amp;lt;code&amp;gt;[in] EOSE sub=sub-1&amp;lt;/code&amp;gt; — &amp;quot;you&#039;ve now seen all stored events; live updates continue.&amp;quot;&lt;br /&gt;
# Now post another note from section 3. It&#039;ll arrive immediately on the same subscription as a live event.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;What just happened&#039;&#039;&#039;&lt;br /&gt;
:A Nostr subscription is a long-lived REQ. Stored events flow first, then EOSE marks the catch-up boundary, then the relay streams new matches in real time until you CLOSE.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;Filters AND across fields, OR across filter objects.&#039;&#039;&#039; &amp;lt;code&amp;gt;{kinds:[1], authors:[X]}&amp;lt;/code&amp;gt; means &amp;quot;kind 1 AND author X&amp;quot;. Adding a second filter to the same REQ means &amp;quot;match either filter&amp;quot;. This is how clients build complex feeds (your follows + your replies + your zaps) in one subscription.&lt;br /&gt;
&lt;br /&gt;
== Lesson 6: Open a second identity in a second window ==&lt;br /&gt;
&lt;br /&gt;
You want to send a message between two of your three whitelisted accounts. Two ways to have two identities at once:&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Option A — Brave private window&#039;&#039;&#039;&lt;br /&gt;
:File → New Private Window. Open &amp;lt;code&amp;gt;nostr-handson.html&amp;lt;/code&amp;gt; there. Load identity B&#039;s nsec.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Option B — Two browsers&#039;&#039;&#039;&lt;br /&gt;
:Brave for identity A, Firefox for identity B (or any other combination).&lt;br /&gt;
&lt;br /&gt;
You should now have &#039;&#039;two browser windows&#039;&#039;, each showing the page, each with a different identity loaded. Treat them as Alice (account A) and Bob (account B).&lt;br /&gt;
&lt;br /&gt;
# In Bob&#039;s window, connect &amp;lt;code&amp;gt;wss://nostr.v4call.com&amp;lt;/code&amp;gt; (Bob&#039;s home relay) AND &amp;lt;code&amp;gt;wss://nostr.hive-book.com&amp;lt;/code&amp;gt; (so Bob can read what Alice writes).&lt;br /&gt;
# In Alice&#039;s window, do the symmetrical: connect both relays.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;The single biggest source of &amp;quot;Nostr is broken&amp;quot; frustration is no shared relay between sender and recipient.&#039;&#039;&#039; Get this dual-relay setup working in both windows and you&#039;ve eliminated 90% of all multi-account Nostr issues.&lt;br /&gt;
&lt;br /&gt;
== Lesson 7: Have a public-note conversation ==&lt;br /&gt;
&lt;br /&gt;
# Alice&#039;s window: section 4 → &amp;lt;strong&amp;gt;Lookup author…&amp;lt;/strong&amp;gt; → paste Bob&#039;s npub. A REQ goes out for kinds 0+1 from Bob.&lt;br /&gt;
# Bob&#039;s window: same in reverse. Look up Alice&#039;s npub.&lt;br /&gt;
# Now Alice posts: &amp;lt;code&amp;gt;&amp;quot;Hi Bob, can you see this?&amp;quot;&amp;lt;/code&amp;gt; Section 5 in Bob&#039;s window shows the incoming EVENT within a second.&lt;br /&gt;
# Bob replies (still kind 1): &amp;lt;code&amp;gt;&amp;quot;Loud and clear, Alice.&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
# Alice&#039;s incoming subscription picks it up.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;This is the entire feed mechanism in Nostr.&#039;&#039;&#039; A real client wraps it in nicer UX, threading, mute lists, follow lists, but the wire protocol is exactly what you&#039;re seeing. There is no central server, no API key, no rate limit beyond what each relay chooses.&lt;br /&gt;
&lt;br /&gt;
== Lesson 8: Send an encrypted DM (NIP-04) ==&lt;br /&gt;
&lt;br /&gt;
# Alice&#039;s window → section 3 → &amp;lt;strong&amp;gt;DM (kind 4 / NIP-04)&amp;lt;/strong&amp;gt; tab.&lt;br /&gt;
# Recipient: paste Bob&#039;s npub.&lt;br /&gt;
# Body: &amp;lt;code&amp;gt;&amp;quot;this is a secret&amp;quot;&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Click &amp;lt;strong&amp;gt;Encrypt, Sign &amp;amp;amp; Publish&amp;lt;/strong&amp;gt;.&lt;br /&gt;
# Watch the outgoing event in section 5. Notice &amp;lt;code&amp;gt;content&amp;lt;/code&amp;gt; is base64 ciphertext like &amp;lt;code&amp;gt;4QwT...?iv=Lk9P...&amp;lt;/code&amp;gt;, not your plaintext. Notice &amp;lt;code&amp;gt;tags: [[&amp;quot;p&amp;quot;, &amp;quot;bob-hex&amp;quot;]]&amp;lt;/code&amp;gt; — that&#039;s how the relay knows who to deliver to.&lt;br /&gt;
# In Bob&#039;s window: section 4 → &amp;lt;strong&amp;gt;DMs to me&amp;lt;/strong&amp;gt;.&lt;br /&gt;
# Bob&#039;s subscription matches kind 4 with &amp;lt;code&amp;gt;#p&amp;lt;/code&amp;gt; tag = Bob&#039;s hex. The relay sends Alice&#039;s encrypted event. Bob&#039;s page &amp;lt;strong&amp;gt;auto-decrypts&amp;lt;/strong&amp;gt; because Bob&#039;s nsec is loaded. The log shows: &amp;lt;code&amp;gt;[in] DM (decrypted) from abc12345…wxyz: this is a secret&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;What just happened&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Encryption (NIP-04 spec)&#039;&#039;&#039;:&lt;br /&gt;
:* shared = ECDH(Alice-priv, Bob-pub) = secp256k1 point&#039;s X coordinate (32 bytes).&lt;br /&gt;
:* iv = 16 random bytes.&lt;br /&gt;
:* ciphertext = AES-256-CBC(shared, iv, plaintext).&lt;br /&gt;
:* content field = base64(ciphertext) + &amp;quot;?iv=&amp;quot; + base64(iv).&lt;br /&gt;
* &#039;&#039;&#039;Decryption&#039;&#039;&#039; is the same with Bob-priv + Alice-pub — ECDH gives the same shared secret in either direction. That&#039;s the magic of Diffie-Hellman.&lt;br /&gt;
* &#039;&#039;&#039;The relay sees&#039;&#039;&#039;: sender pubkey, recipient pubkey (from p-tag), timestamp, ciphertext. It does NOT see the body.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;⚠ Wisdom — NIP-04 limits&#039;&#039;&#039;&lt;br /&gt;
:* Metadata (who-talks-to-whom + when + how often) is plaintext on the relay. A relay operator running a logger can build the social graph.&lt;br /&gt;
:* NIP-44 is a newer encryption (ChaCha20 + HKDF + HMAC). Better authenticated encryption. Most clients support it.&lt;br /&gt;
:* NIP-17 wraps NIP-44 in &amp;quot;gift wraps&amp;quot; (kind 1059) that hide sender + recipient on the relay. Modern best practice. Not implemented in this page yet — too complex for stage 2.&lt;br /&gt;
:* &#039;&#039;&#039;For real privacy use a client that supports NIP-17.&#039;&#039;&#039; This page is for &#039;&#039;learning&#039;&#039;, not for OPSEC.&lt;br /&gt;
&lt;br /&gt;
== Lesson 9: Read the raw JSON ==&lt;br /&gt;
&lt;br /&gt;
Open the browser console (F12 → Console tab). Filter / search for &amp;lt;code&amp;gt;[in]&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;[out]&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;An outgoing kind 1&#039;&#039;&#039; has the canonical structure from lesson 3.&lt;br /&gt;
* &#039;&#039;&#039;An outgoing kind 4&#039;&#039;&#039; is the same shape but: kind=4, content=ciphertext, tags=[[&amp;quot;p&amp;quot;, recipient]].&lt;br /&gt;
* &#039;&#039;&#039;An incoming EVENT&#039;&#039;&#039; is a 3-tuple: &amp;lt;code&amp;gt;[&amp;quot;EVENT&amp;quot;, subId, eventObject]&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;An incoming OK&#039;&#039;&#039; is a 4-tuple: &amp;lt;code&amp;gt;[&amp;quot;OK&amp;quot;, eventId, accepted (bool), reason (string)]&amp;lt;/code&amp;gt;. &amp;lt;code&amp;gt;accepted=false&amp;lt;/code&amp;gt; with a clear reason is how relays reject (e.g. &amp;lt;code&amp;gt;&amp;quot;blocked: pubkey not authorized to publish&amp;quot;&amp;lt;/code&amp;gt;).&lt;br /&gt;
* &#039;&#039;&#039;A NOTICE&#039;&#039;&#039; is a server-side gripe: &amp;lt;code&amp;gt;[&amp;quot;NOTICE&amp;quot;, &amp;quot;could not parse command&amp;quot;]&amp;lt;/code&amp;gt;. You saw this in stage 1 lesson 14.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Try this in the console&#039;&#039;&#039;:&lt;br /&gt;
:After connecting a relay, type:&lt;br /&gt;
: &amp;lt;code&amp;gt;[&amp;quot;EVENT&amp;quot;, &amp;quot;fakeId&amp;quot;]&amp;lt;/code&amp;gt;   ← intentionally malformed&lt;br /&gt;
:Then send it manually:&lt;br /&gt;
: &amp;lt;code&amp;gt;state.relays.get(&#039;wss://nostr.hive-book.com&#039;).ws.send(JSON.stringify([&amp;quot;EVENT&amp;quot;,&amp;quot;fakeId&amp;quot;]))&amp;lt;/code&amp;gt;&lt;br /&gt;
:You&#039;ll get a NOTICE back from the relay rejecting the malformed message. Your page literally bypasses no security to do this — you&#039;re just speaking the protocol directly.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;Once you can build and parse these JSON shapes by hand, you can write a Nostr client.&#039;&#039;&#039; That&#039;s the whole protocol. Everything else is UX.&lt;br /&gt;
&lt;br /&gt;
== Lesson 10: Watch the firehose (a public relay) ==&lt;br /&gt;
&lt;br /&gt;
# Make sure &amp;lt;code&amp;gt;wss://relay.damus.io&amp;lt;/code&amp;gt; is connected (it&#039;s pre-filled).&lt;br /&gt;
# Section 4 → &amp;lt;strong&amp;gt;Firehose&amp;lt;/strong&amp;gt; button. This sends &amp;lt;code&amp;gt;{kinds:[1], limit:20}&amp;lt;/code&amp;gt; with no author filter.&lt;br /&gt;
# Watch the log fill with kind-1 notes from random Nostr users worldwide.&lt;br /&gt;
# Notice each one has a different &amp;lt;code&amp;gt;pubkey&amp;lt;/code&amp;gt;. Each one is independently signed.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;A public relay is just a private relay without the whitelist.&#039;&#039;&#039; Same protocol, same code, same &amp;lt;code&amp;gt;nostr-rs-relay&amp;lt;/code&amp;gt; binary on the other end (or its peers). Yours is private only because of three lines in &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Lesson 11: Server-side view (Docker logs + nak) ==&lt;br /&gt;
&lt;br /&gt;
SSH into your relay box. In one terminal:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay &amp;amp;&amp;amp; docker compose logs -f nostr-relay&lt;br /&gt;
&lt;br /&gt;
In another terminal (or back on your laptop):&lt;br /&gt;
&lt;br /&gt;
 nak req -k 4 -a YOUR_HEX_PUBKEY wss://nostr.hive-book.com&lt;br /&gt;
&lt;br /&gt;
Now from the page, send another note. Watch the docker logs — the relay&#039;s perspective: a connection arrives, an EVENT is received, it&#039;s persisted, an OK goes back. Send a kind 4 — same flow, but the relay only sees ciphertext.&lt;br /&gt;
&lt;br /&gt;
Try this with a non-whitelisted key (load a fresh disposable nsec, try to publish): the relay logs the rejection, the page receives &amp;lt;code&amp;gt;OK accepted=false reason=&amp;quot;blocked: ...&amp;quot;&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;Relays are accountable infrastructure.&#039;&#039;&#039; You can audit exactly what you accepted and rejected. This is part of why people run their own — you control the policy and you can prove you&#039;re enforcing it.&lt;br /&gt;
&lt;br /&gt;
== Lesson 12: Break things on purpose ==&lt;br /&gt;
&lt;br /&gt;
Healthy thing to do — surface error paths so you&#039;ll recognise them later.&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Disconnect a relay, then publish.&#039;&#039;&#039; Page shows &amp;lt;code&amp;gt;[error] No open relays — nothing was published&amp;lt;/code&amp;gt;.&lt;br /&gt;
# &#039;&#039;&#039;Publish without a key loaded.&#039;&#039;&#039; Page alerts. (Try this in the console too: clear &amp;lt;code&amp;gt;state.privkey&amp;lt;/code&amp;gt; and click Send.)&lt;br /&gt;
# &#039;&#039;&#039;Subscribe with a malformed filter.&#039;&#039;&#039; From the console: &amp;lt;code&amp;gt;state.relays.get(&#039;wss://nostr.hive-book.com&#039;).ws.send(JSON.stringify([&amp;quot;REQ&amp;quot;,&amp;quot;junk&amp;quot;,{&amp;quot;foo&amp;quot;:&amp;quot;bar&amp;quot;}]))&amp;lt;/code&amp;gt; — the relay either ignores or NOTICEs.&lt;br /&gt;
# &#039;&#039;&#039;Send a DM to yourself.&#039;&#039;&#039; Auto-decrypts. Useful for confirming the encrypt/decrypt round-trip works locally without involving a second account.&lt;br /&gt;
# &#039;&#039;&#039;Refresh the page mid-session.&#039;&#039;&#039; Privkey is forgotten. Relays disconnect. Section 5 clears. Nothing persists. Re-paste nsec and reconnect. (This is by design — keys never persist.)&lt;br /&gt;
# &#039;&#039;&#039;Cancel all subs, then post.&#039;&#039;&#039; The post still goes out (publishing is independent of subscriptions). Subs only affect &#039;&#039;reading&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;💡 Wisdom&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;Most &amp;quot;Nostr won&#039;t work&amp;quot; reports are one of: no key loaded, no relay open, sender/recipient relay mismatch, or a stale subscription.&#039;&#039;&#039; Once you&#039;ve intentionally hit each of these in a controlled setting, you&#039;ll diagnose them in seconds in the wild.&lt;br /&gt;
&lt;br /&gt;
== What this page does NOT do ==&lt;br /&gt;
&lt;br /&gt;
Stage 2 deliberately stops where the protocol ends and where &#039;&#039;client UX&#039;&#039; begins. The page does NOT:&lt;br /&gt;
&lt;br /&gt;
* Render profiles (kind 0 metadata) — you only see &amp;quot;from &amp;lt;hex&amp;gt;&amp;quot;.&lt;br /&gt;
* Build threaded reply trees (NIP-10 e/p tag markers).&lt;br /&gt;
* Render reactions (kind 7) as little hearts.&lt;br /&gt;
* Auto-discover who&#039;s on which relay (NIP-65 outbox model).&lt;br /&gt;
* Show notifications, mentions, or unread counts.&lt;br /&gt;
* Handle long-form articles (kind 30023) with markdown.&lt;br /&gt;
* Send or receive zaps (NIP-57 Lightning).&lt;br /&gt;
* Use modern DMs (NIP-17 gift-wrapped). NIP-04 only.&lt;br /&gt;
* Maintain mute lists, follow lists, or any persistence between page loads.&lt;br /&gt;
* Use a NIP-07 browser extension for safer key handling.&lt;br /&gt;
&lt;br /&gt;
Each of these is a layer that real clients add on top of the protocol. Once you&#039;re comfortable with what&#039;s happening here, every one of them makes sense as a small specific addition rather than as opaque magic.&lt;br /&gt;
&lt;br /&gt;
== Wisdom for the Nostr Noob ==&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Use disposable keys for this page&#039;&#039;&#039;&lt;br /&gt;
:The privkey lives in browser JS memory. Acceptable for learning, not for valuable identities. For real use → NIP-07 extension (nos2x / Alby) or a remote signer (NIP-46 / Nsec.app).&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;No shared relay = no message delivered&#039;&#039;&#039;&lt;br /&gt;
:If sender&#039;s writes and recipient&#039;s reads don&#039;t overlap, you&#039;re shouting into the void. There is no central postman. This is THE concept.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;The console is your second feed&#039;&#039;&#039;&lt;br /&gt;
:Section 5 is a curated UI; the browser console (F12) has every byte mirrored to &amp;lt;code&amp;gt;console.log()&amp;lt;/code&amp;gt;. Use Ctrl+F to search across hundreds of events. Filter by &amp;lt;code&amp;gt;[in]&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;[out]&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;[system]&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;[error]&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Read the page source&#039;&#039;&#039;&lt;br /&gt;
:All logic is in one &amp;lt;code&amp;gt;&amp;amp;lt;script type=&amp;quot;module&amp;quot;&amp;amp;gt;&amp;lt;/code&amp;gt; block at the bottom of the file. ~600 lines, well-commented. Edit it. Add a button. Add a kind. The page is yours — break it, learn from breaking it.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;nak is the second tool you&#039;ll always reach for&#039;&#039;&#039;&lt;br /&gt;
:When the page says &amp;quot;I sent it&amp;quot;, confirm with &amp;lt;code&amp;gt;nak req -k 1 -a HEX wss://...&amp;lt;/code&amp;gt;. When the page says &amp;quot;nothing came back&amp;quot;, confirm with the same command. The two tools cross-check each other and remove all &amp;quot;but the client said...&amp;quot; doubt.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Kinds are the schema&#039;&#039;&#039;&lt;br /&gt;
:0 = profile, 1 = note, 3 = follow list, 4 = old DM, 5 = delete request, 6 = repost, 7 = reaction, 9735 = zap receipt, 10002 = relay list, 30023 = long-form article, 1059 = NIP-17 gift wrap, ... hundreds of NIPs. The full list lives at [https://github.com/nostr-protocol/nips github.com/nostr-protocol/nips]. Surprisingly readable.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Time stamps are seconds, not milliseconds&#039;&#039;&#039;&lt;br /&gt;
:Nostr&#039;s &amp;lt;code&amp;gt;created_at&amp;lt;/code&amp;gt; is Unix seconds (&amp;lt;code&amp;gt;Math.floor(Date.now()/1000)&amp;lt;/code&amp;gt;). Putting milliseconds will make events look ~50 years in the future and many relays drop them.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;&amp;quot;Delete&amp;quot; is fuzzy&#039;&#039;&#039;&lt;br /&gt;
:NIP-09 has a &amp;quot;request deletion&amp;quot; event. Relays may or may not honour it. Other relays still have copies. Treat anything you publish as permanent.&lt;br /&gt;
&lt;br /&gt;
== Where to go after this ==&lt;br /&gt;
&lt;br /&gt;
In rough order of &amp;quot;what to learn next&amp;quot;:&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;NIP-65 outbox model&#039;&#039;&#039; — publish a relay-list event (kind 10002) declaring your read + write relays. This is the spec for &amp;quot;where can people find me.&amp;quot; Most modern clients use it. Add a &amp;quot;publish my relay list&amp;quot; button to &amp;lt;code&amp;gt;nostr-handson.html&amp;lt;/code&amp;gt; as a first hack.&lt;br /&gt;
# &#039;&#039;&#039;Profile metadata (kind 0)&#039;&#039;&#039; — set name, about, picture. Same shape as kind 1, but content is JSON. Replaceable: only the latest event survives per pubkey.&lt;br /&gt;
# &#039;&#039;&#039;NIP-05 verification&#039;&#039;&#039; — map &amp;lt;code&amp;gt;username@yourdomain.com&amp;lt;/code&amp;gt; to your pubkey via a &amp;lt;code&amp;gt;/.well-known/nostr.json&amp;lt;/code&amp;gt; on your domain. Stage 1&#039;s relays could host this trivially.&lt;br /&gt;
# &#039;&#039;&#039;NIP-07 (browser extension signing)&#039;&#039;&#039; — install nos2x or Alby. Modify &amp;lt;code&amp;gt;nostr-handson.html&amp;lt;/code&amp;gt; so it calls &amp;lt;code&amp;gt;window.nostr.signEvent()&amp;lt;/code&amp;gt; instead of holding the privkey. This is the right pattern for any real key.&lt;br /&gt;
# &#039;&#039;&#039;NIP-44 + NIP-17&#039;&#039;&#039; — modern DMs. NIP-44 = better symmetric encryption. NIP-17 = wrap NIP-44 in gift wraps so the relay can&#039;t see who-talked-to-whom.&lt;br /&gt;
# &#039;&#039;&#039;Zaps (NIP-57)&#039;&#039;&#039; — Lightning tipping over Nostr. You&#039;ll need a Lightning wallet first.&lt;br /&gt;
# &#039;&#039;&#039;A real client&#039;&#039;&#039; — Primal, nostrudel, Damus, Amethyst, Iris, Coracle, Snort, Habla. Pick one. Notice that under the hood it&#039;s doing exactly what your page does, just with prettier UX.&lt;br /&gt;
# &#039;&#039;&#039;v4call&#039;s planned Nostr layer&#039;&#039;&#039; — see [[V4call]] and the project&#039;s NOSTR-DESIGN-NOTES.md. Your private relays are the natural infrastructure for that, with one of your 3 keys being your v4call server&#039;s identity.&lt;br /&gt;
&lt;br /&gt;
You now have a Nostr identity you understand, a relay you control, and a tool that exposes the entire protocol. That&#039;s a stronger foundation than 99% of Nostr users start with. 🌿&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Nostr-relay-with-whitelist&amp;diff=754</id>
		<title>Nostr-relay-with-whitelist</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Nostr-relay-with-whitelist&amp;diff=754"/>
		<updated>2026-05-08T17:55:52Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;{{:LICENCE_HEADER_MIT}}   = Nostr Relay With a 3-Key Whitelist — Two Servers on Ubuntu 24.04 with Docker =  From CompleteNoobs  This guide walks through deploying &amp;#039;&amp;#039;&amp;#039;two private Nostr relays&amp;#039;&amp;#039;&amp;#039; from scratch on fresh Vultr Ubuntu 24.04 VPS boxes — one at &amp;lt;code&amp;gt;nostr.hive-book.com&amp;lt;/code&amp;gt;, one at &amp;lt;code&amp;gt;nostr.v4call.com&amp;lt;/code&amp;gt; — locked down so that only &amp;#039;&amp;#039;&amp;#039;3 specific npub keys&amp;#039;&amp;#039;&amp;#039; can publish events. The relays will serve over secure WebSocket (&amp;lt;code&amp;gt;wss://&amp;lt;/code&amp;gt;) with...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_MIT}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= Nostr Relay With a 3-Key Whitelist — Two Servers on Ubuntu 24.04 with Docker =&lt;br /&gt;
&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
&lt;br /&gt;
This guide walks through deploying &#039;&#039;&#039;two private Nostr relays&#039;&#039;&#039; from scratch on fresh Vultr Ubuntu 24.04 VPS boxes — one at &amp;lt;code&amp;gt;nostr.hive-book.com&amp;lt;/code&amp;gt;, one at &amp;lt;code&amp;gt;nostr.v4call.com&amp;lt;/code&amp;gt; — locked down so that only &#039;&#039;&#039;3 specific npub keys&#039;&#039;&#039; can publish events. The relays will serve over secure WebSocket (&amp;lt;code&amp;gt;wss://&amp;lt;/code&amp;gt;) with auto-renewing Let&#039;s Encrypt certificates.&lt;br /&gt;
&lt;br /&gt;
This is a &amp;quot;noob doing Nostr by doing&amp;quot; walkthrough. If you have never run a Nostr relay before, you are in the right place. The same steps work for one relay or fifty — repeat Steps 1–14 for each box, just with a different domain name.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;End result&#039;&#039;&#039;:&lt;br /&gt;
* &amp;lt;code&amp;gt;wss://nostr.hive-book.com&amp;lt;/code&amp;gt; — accepts events only from your 3 whitelisted keys&lt;br /&gt;
* &amp;lt;code&amp;gt;wss://nostr.v4call.com&amp;lt;/code&amp;gt; — accepts events only from your 3 whitelisted keys&lt;br /&gt;
* Anyone in the world can &#039;&#039;read&#039;&#039; events from these relays; only your 3 keys can &#039;&#039;write&#039;&#039; to them&lt;br /&gt;
* TLS certificates auto-renew, no manual fiddling&lt;br /&gt;
&lt;br /&gt;
== Quick Background — What is a Nostr Relay? ==&lt;br /&gt;
&lt;br /&gt;
If you are brand new to Nostr, here is the 60-second version:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Nostr&#039;&#039;&#039; = &amp;quot;Notes and Other Stuff Transmitted by Relays&amp;quot;. A simple decentralised protocol — no blockchain, no accounts to register, no central server.&lt;br /&gt;
* &#039;&#039;&#039;Identity&#039;&#039;&#039; is just a public key (an &amp;quot;npub&amp;quot;, e.g. &amp;lt;code&amp;gt;npub1abc...xyz&amp;lt;/code&amp;gt;). You sign your messages (&amp;quot;events&amp;quot;) with your private key (&amp;quot;nsec&amp;quot;). Anyone can verify the signature with your npub.&lt;br /&gt;
* &#039;&#039;&#039;Relays&#039;&#039;&#039; are dumb servers that store and forward signed events. They do not own your identity. If one relay bans you, you publish to a different one. Your followers&#039; clients query multiple relays and merge the results.&lt;br /&gt;
* &#039;&#039;&#039;Why run your own relay?&#039;&#039;&#039; Independence. Censorship-resistance. Speed. And — for this guide — the ability to run a &#039;&#039;private&#039;&#039; relay where only you (and your 2 friends, business partners, family — whoever holds the 3 whitelisted keys) can publish.&lt;br /&gt;
&lt;br /&gt;
A &amp;quot;whitelisted-write, public-read&amp;quot; relay is a great starter project. Outsiders can&#039;t spam it; you and your trusted keys can post freely; the world can still read your events if they know the relay URL. It&#039;s also useful infrastructure for v4call&#039;s federation discovery layer (see [[V4call]]).&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
&lt;br /&gt;
* [[#What_You_Need|1 What You Need]]&lt;br /&gt;
* [[#Step_1:_Get_Your_3_Whitelisted_npubs_Ready|2 Step 1: Get Your 3 Whitelisted npubs Ready]]&lt;br /&gt;
* [[#Step_2:_Convert_npub_to_hex|3 Step 2: Convert npub to hex]]&lt;br /&gt;
* [[#Step_3:_Create_Your_Vultr_VPS_(x2)|4 Step 3: Create Your Vultr VPS (x2)]]&lt;br /&gt;
* [[#Step_4:_Point_Your_Domains_at_the_VPS_Boxes|5 Step 4: Point Your Domains at the VPS Boxes]]&lt;br /&gt;
* [[#Step_5:_Log_into_Your_VPS|6 Step 5: Log into Your VPS]]&lt;br /&gt;
* [[#Step_6:_Update_and_Secure_the_Server|7 Step 6: Update and Secure the Server]]&lt;br /&gt;
* [[#Step_7:_Install_Docker|8 Step 7: Install Docker]]&lt;br /&gt;
* [[#Step_8:_Open_the_Firewall|9 Step 8: Open the Firewall]]&lt;br /&gt;
* [[#Step_9:_Create_the_Relay_Project_Folder|10 Step 9: Create the Relay Project Folder]]&lt;br /&gt;
* [[#Step_10:_Write_the_Relay_Config|11 Step 10: Write the Relay Config (config.toml)]]&lt;br /&gt;
* [[#Step_11:_Write_the_Caddy_Config|12 Step 11: Write the Caddy Config (Caddyfile)]]&lt;br /&gt;
* [[#Step_12:_Write_the_docker-compose.yml|13 Step 12: Write the docker-compose.yml]]&lt;br /&gt;
* [[#Step_13:_Start_It_Up|14 Step 13: Start It Up]]&lt;br /&gt;
* [[#Step_14:_Test_the_Relay|15 Step 14: Test the Relay]]&lt;br /&gt;
* [[#Step_15:_Repeat_for_the_Second_Server|16 Step 15: Repeat for the Second Server]]&lt;br /&gt;
* [[#Updating_the_Whitelist|17 Updating the Whitelist]]&lt;br /&gt;
* [[#Common_Problems_and_Fixes|18 Common Problems and Fixes]]&lt;br /&gt;
* [[#Tips_for_Nostr_Noobs|19 Tips for Nostr Noobs]]&lt;br /&gt;
&lt;br /&gt;
== What You Need ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;A Vultr account&#039;&#039;&#039; — sign up at [https://vultr.com vultr.com]. Please use our [https://www.vultr.com/?ref=7704739 Vultr Referral link] to help cover server costs.&lt;br /&gt;
* &#039;&#039;&#039;Two domain names (or subdomains)&#039;&#039;&#039; with DNS access — for this guide: &amp;lt;code&amp;gt;nostr.hive-book.com&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;nostr.v4call.com&amp;lt;/code&amp;gt;. Substitute your own.&lt;br /&gt;
* &#039;&#039;&#039;Three Nostr key pairs&#039;&#039;&#039; — the 3 npubs that will be allowed to publish. If you don&#039;t have them yet, see Step 1.&lt;br /&gt;
* &#039;&#039;&#039;A terminal&#039;&#039;&#039; — Mac: Terminal. Windows: PowerShell or PuTTY. Linux: whatever you&#039;ve got.&lt;br /&gt;
* &#039;&#039;&#039;Around 30–45 minutes&#039;&#039;&#039; per server — most of which is waiting for DNS, apt updates, and Docker pulls.&lt;br /&gt;
&lt;br /&gt;
You do not need to know Rust, Go, or any programming. Every command can be copy-pasted exactly as shown.&lt;br /&gt;
&lt;br /&gt;
== Step 1: Get Your 3 Whitelisted npubs Ready ==&lt;br /&gt;
&lt;br /&gt;
You need 3 Nostr public keys (npubs). These are the only keys that will be allowed to publish events to your relays.&lt;br /&gt;
&lt;br /&gt;
If you don&#039;t have them yet, generate them. Pick whichever method you like:&lt;br /&gt;
&lt;br /&gt;
;Option A — Browser-only generator (recommended, no install)&lt;br /&gt;
:If you have v4call cloned, open &amp;lt;code&amp;gt;public/nostr-gen.html&amp;lt;/code&amp;gt; in any browser. It generates an npub + nsec entirely in the browser using &amp;lt;code&amp;gt;window.crypto.subtle&amp;lt;/code&amp;gt;. The keys never touch a network.&lt;br /&gt;
:Save the npub (public, fine to share) and the nsec (private, NEVER share, treat like a posting key).&lt;br /&gt;
&lt;br /&gt;
;Option B — Use a Nostr client you already use&lt;br /&gt;
:Damus, Amethyst, Iris, nos2x, Alby, etc. all let you export your npub. Settings → Profile → Public key.&lt;br /&gt;
&lt;br /&gt;
;Option C — Command-line with &amp;lt;code&amp;gt;nak&amp;lt;/code&amp;gt;&lt;br /&gt;
:Install [https://github.com/fiatjaf/nak nak]: &amp;lt;code&amp;gt;go install github.com/fiatjaf/nak@latest&amp;lt;/code&amp;gt;&lt;br /&gt;
:Then run &amp;lt;code&amp;gt;nak key generate&amp;lt;/code&amp;gt; to make a new pair.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;⚠ Do this 3 times if you need 3 fresh pairs.&#039;&#039;&#039; Or use existing keys you already trust — the 3 don&#039;t have to be new.&lt;br /&gt;
&lt;br /&gt;
Write down the 3 npubs in a notes file. Example:&lt;br /&gt;
&lt;br /&gt;
 npub1aaaa...    (your daily key)&lt;br /&gt;
 npub1bbbb...    (a partner&#039;s key)&lt;br /&gt;
 npub1cccc...    (your v4call server&#039;s identity key)&lt;br /&gt;
&lt;br /&gt;
== Step 2: Convert npub to hex ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important&#039;&#039;&#039;: Nostr relays internally use the &#039;&#039;&#039;hex&#039;&#039;&#039; form of public keys, not the bech32 npub form. Same key, different encoding. &amp;lt;code&amp;gt;nostr-rs-relay&amp;lt;/code&amp;gt;&#039;s whitelist needs the hex.&lt;br /&gt;
&lt;br /&gt;
;Easiest converter — nostr-gen.html&lt;br /&gt;
:If you generated keys via &amp;lt;code&amp;gt;nostr-gen.html&amp;lt;/code&amp;gt;, the page already shows both forms. Copy the hex.&lt;br /&gt;
&lt;br /&gt;
;Web converter&lt;br /&gt;
:[https://nostrcheck.me/converter/ nostrcheck.me/converter] — paste npub, get hex. (Use any reputable converter; the math is public.)&lt;br /&gt;
&lt;br /&gt;
;Command-line&lt;br /&gt;
:With nak: &amp;lt;code&amp;gt;nak decode npub1aaaa...&amp;lt;/code&amp;gt; — prints the hex pubkey.&lt;br /&gt;
:With Python: &amp;lt;code&amp;gt;pip install bech32&amp;lt;/code&amp;gt;, then a 3-line script. (Out of scope here.)&lt;br /&gt;
&lt;br /&gt;
You should end up with 3 hex strings, each 64 characters long (lowercase, 0-9 and a-f only). Example:&lt;br /&gt;
&lt;br /&gt;
 npub1aaaa...    →    abc123def456...   (64 hex chars)&lt;br /&gt;
 npub1bbbb...    →    fff789abc012...   (64 hex chars)&lt;br /&gt;
 npub1cccc...    →    111aaa222bbb...   (64 hex chars)&lt;br /&gt;
&lt;br /&gt;
Save these. You will paste them into &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; in Step 10.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Tip&#039;&#039;&#039;: If the converter spits out anything other than 64 hex chars, you pasted the wrong thing (probably an nsec — that&#039;s the private key, never paste it into a converter you don&#039;t fully trust). Double-check the input started with &amp;lt;code&amp;gt;npub1&amp;lt;/code&amp;gt;, not &amp;lt;code&amp;gt;nsec1&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Create Your Vultr VPS (x2) ==&lt;br /&gt;
&lt;br /&gt;
You need &#039;&#039;&#039;two&#039;&#039;&#039; VPS boxes — one for each relay. Repeat this step twice. Name them differently (e.g. &amp;lt;code&amp;gt;nostr-hivebook&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;nostr-v4call&amp;lt;/code&amp;gt;) so you don&#039;t confuse them later.&lt;br /&gt;
&lt;br /&gt;
# Log into [https://my.vultr.com my.vultr.com]&lt;br /&gt;
# Click &#039;&#039;&#039;Deploy New Server&#039;&#039;&#039;&lt;br /&gt;
# Choose &#039;&#039;&#039;Cloud Compute — Shared CPU&#039;&#039;&#039;&lt;br /&gt;
# Choose a location close to where most of your readers are&lt;br /&gt;
# Choose &#039;&#039;&#039;Ubuntu 24.04 LTS x64&#039;&#039;&#039;&lt;br /&gt;
# Choose the &#039;&#039;&#039;$6/month&#039;&#039;&#039; plan (1 CPU, 1GB RAM, 25GB SSD) — plenty for a private relay with 3 publishers&lt;br /&gt;
# Set Server Hostname to something like &amp;lt;code&amp;gt;nostr-hivebook&amp;lt;/code&amp;gt;&lt;br /&gt;
# Click &#039;&#039;&#039;Deploy Now&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Wait ~60 seconds for it to start. Click the server to find:&lt;br /&gt;
* &#039;&#039;&#039;IP Address&#039;&#039;&#039; — looks like &amp;lt;code&amp;gt;123.456.789.012&amp;lt;/code&amp;gt; — write it down&lt;br /&gt;
* &#039;&#039;&#039;Password&#039;&#039;&#039; — click the eye icon — write it down&lt;br /&gt;
&lt;br /&gt;
Repeat for the second server. Now you should have:&lt;br /&gt;
&lt;br /&gt;
 nostr-hivebook    →   IP_A   password_A&lt;br /&gt;
 nostr-v4call      →   IP_B   password_B&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Tip&#039;&#039;&#039;: $6/month per relay is fine for low-traffic private relays. If your 3 keys post infrequently and the world reads occasionally, you&#039;ll never hit any limits. If you want to be cheap, $3.50/month boxes also work (512MB RAM) but pulls/builds are slower.&lt;br /&gt;
&lt;br /&gt;
== Step 4: Point Your Domains at the VPS Boxes ==&lt;br /&gt;
&lt;br /&gt;
Log into your DNS provider for each domain and add an &#039;&#039;&#039;A&#039;&#039;&#039; record:&lt;br /&gt;
&lt;br /&gt;
;For &amp;lt;code&amp;gt;nostr.hive-book.com&amp;lt;/code&amp;gt;&lt;br /&gt;
:{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Field !! Value&lt;br /&gt;
|-&lt;br /&gt;
| Type || A&lt;br /&gt;
|-&lt;br /&gt;
| Name || &amp;lt;code&amp;gt;nostr&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Value || IP_A (the hive-book VPS)&lt;br /&gt;
|-&lt;br /&gt;
| TTL || 300&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
;For &amp;lt;code&amp;gt;nostr.v4call.com&amp;lt;/code&amp;gt;&lt;br /&gt;
:Same setup, but Name = &amp;lt;code&amp;gt;nostr&amp;lt;/code&amp;gt;, Value = IP_B (the v4call VPS).&lt;br /&gt;
&lt;br /&gt;
DNS takes a few minutes to propagate. From your computer:&lt;br /&gt;
&lt;br /&gt;
 nslookup nostr.hive-book.com&lt;br /&gt;
 nslookup nostr.v4call.com&lt;br /&gt;
&lt;br /&gt;
Each must show the correct VPS IP before Step 13 (Caddy needs the DNS to resolve to fetch a certificate). You can continue with the other steps while waiting.&lt;br /&gt;
&lt;br /&gt;
== Step 5: Log into Your VPS ==&lt;br /&gt;
&lt;br /&gt;
Open a terminal:&lt;br /&gt;
&lt;br /&gt;
 ssh root@IP_A&lt;br /&gt;
&lt;br /&gt;
Type &amp;lt;code&amp;gt;yes&amp;lt;/code&amp;gt; at the fingerprint prompt, then paste the password from Vultr (right-click to paste in most terminals).&lt;br /&gt;
&lt;br /&gt;
If Vultr forces a password change on first login, set a strong new one and write it down.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Repeat the rest of this guide on both boxes.&#039;&#039;&#039; We&#039;ll do them one at a time. Steps 6–14 are identical on both — only the domain name in Step 11 (Caddyfile) changes.&lt;br /&gt;
&lt;br /&gt;
== Step 6: Update and Secure the Server ==&lt;br /&gt;
&lt;br /&gt;
Always start with a system update:&lt;br /&gt;
&lt;br /&gt;
 apt update &amp;amp;&amp;amp; apt upgrade -y&lt;br /&gt;
&lt;br /&gt;
This may take a few minutes. If it asks about restarting services, hit Enter to accept defaults.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Optional but recommended — make a non-root user&#039;&#039;&#039; (you can skip if you&#039;re the only person ever touching this box and you trust your password):&lt;br /&gt;
&lt;br /&gt;
 adduser nostr&lt;br /&gt;
 usermod -aG sudo nostr&lt;br /&gt;
&lt;br /&gt;
Then later you can &amp;lt;code&amp;gt;ssh nostr@IP_A&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;sudo&amp;lt;/code&amp;gt; for privileged commands. For this guide we&#039;ll just stay as root for simplicity — fine for a single-purpose box.&lt;br /&gt;
&lt;br /&gt;
== Step 7: Install Docker ==&lt;br /&gt;
&lt;br /&gt;
Install Docker the official way:&lt;br /&gt;
&lt;br /&gt;
 apt install -y ca-certificates curl&lt;br /&gt;
 install -m 0755 -d /etc/apt/keyrings&lt;br /&gt;
 curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc&lt;br /&gt;
 chmod a+r /etc/apt/keyrings/docker.asc&lt;br /&gt;
 echo &amp;quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release &amp;amp;&amp;amp; echo &amp;quot;$VERSION_CODENAME&amp;quot;) stable&amp;quot; &amp;gt; /etc/apt/sources.list.d/docker.list&lt;br /&gt;
 apt update&lt;br /&gt;
 apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin&lt;br /&gt;
&lt;br /&gt;
Verify:&lt;br /&gt;
&lt;br /&gt;
 docker --version&lt;br /&gt;
 docker compose version&lt;br /&gt;
&lt;br /&gt;
You should see version numbers for both. If not, re-run the apt install line.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Tip&#039;&#039;&#039;: &amp;lt;code&amp;gt;docker compose&amp;lt;/code&amp;gt; (with a space) is the new way. The old &amp;lt;code&amp;gt;docker-compose&amp;lt;/code&amp;gt; (with a hyphen) is deprecated. This guide uses the new form.&lt;br /&gt;
&lt;br /&gt;
== Step 8: Open the Firewall ==&lt;br /&gt;
&lt;br /&gt;
Vultr boxes start with no firewall, but it&#039;s good hygiene to enable one. We need ports 22 (SSH), 80 (HTTP, for Let&#039;s Encrypt), and 443 (HTTPS/WSS).&lt;br /&gt;
&lt;br /&gt;
 ufw allow 22/tcp&lt;br /&gt;
 ufw allow 80/tcp&lt;br /&gt;
 ufw allow 443/tcp&lt;br /&gt;
 ufw --force enable&lt;br /&gt;
 ufw status&lt;br /&gt;
&lt;br /&gt;
You should see all three ports listed as ALLOW.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;⚠ Important&#039;&#039;&#039;: do NOT skip port 80. Caddy uses HTTP-01 ACME challenges to get the TLS certificate, which needs port 80 reachable from the internet. Closing it = no certificate = no &amp;lt;code&amp;gt;wss://&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Step 9: Create the Relay Project Folder ==&lt;br /&gt;
&lt;br /&gt;
 mkdir -p /opt/nostr-relay&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 mkdir -p data caddy_data caddy_config&lt;br /&gt;
&lt;br /&gt;
You&#039;ll create three files in &amp;lt;code&amp;gt;/opt/nostr-relay&amp;lt;/code&amp;gt;:&lt;br /&gt;
* &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; — relay configuration (whitelist lives here)&lt;br /&gt;
* &amp;lt;code&amp;gt;Caddyfile&amp;lt;/code&amp;gt; — TLS reverse proxy config (domain name lives here)&lt;br /&gt;
* &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt; — orchestration&lt;br /&gt;
&lt;br /&gt;
== Step 10: Write the Relay Config (config.toml) ==&lt;br /&gt;
&lt;br /&gt;
We&#039;ll use [https://github.com/scsibug/nostr-rs-relay nostr-rs-relay] — a battle-tested Rust relay with a simple TOML config and a built-in pubkey whitelist (no plugins, no extra daemon).&lt;br /&gt;
&lt;br /&gt;
Create the config file:&lt;br /&gt;
&lt;br /&gt;
 nano /opt/nostr-relay/config.toml&lt;br /&gt;
&lt;br /&gt;
Paste this — &#039;&#039;&#039;replace the three hex strings&#039;&#039;&#039; in &amp;lt;code&amp;gt;pubkey_whitelist&amp;lt;/code&amp;gt; with the 3 hex pubkeys from Step 2:&lt;br /&gt;
&amp;lt;!-- use pre tags or it breaks wiki syntax --&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
 [info]&lt;br /&gt;
 relay_url = &amp;quot;wss://nostr.hive-book.com/&amp;quot;&lt;br /&gt;
 name = &amp;quot;hive-book private relay&amp;quot;&lt;br /&gt;
 description = &amp;quot;Private write-whitelisted relay. Public read.&amp;quot;&lt;br /&gt;
 # pubkey of the relay operator (hex). Optional but nice.&lt;br /&gt;
 # pubkey = &amp;quot;your-operator-hex-pubkey&amp;quot;&lt;br /&gt;
 # contact = &amp;quot;mailto:you@example.com&amp;quot;&lt;br /&gt;
&lt;br /&gt;
 [database]&lt;br /&gt;
 data_directory = &amp;quot;/usr/src/app/db&amp;quot;&lt;br /&gt;
&lt;br /&gt;
 [network]&lt;br /&gt;
 # Bind inside the container; Caddy will reverse-proxy from the public 443.&lt;br /&gt;
 address = &amp;quot;0.0.0.0&amp;quot;&lt;br /&gt;
 port = 8080&lt;br /&gt;
&lt;br /&gt;
 [limits]&lt;br /&gt;
 messages_per_sec = 10&lt;br /&gt;
 subscriptions_per_min = 30&lt;br /&gt;
 max_event_bytes = 131072      # 128 KB per event — plenty for normal Nostr use&lt;br /&gt;
 max_ws_message_bytes = 131072&lt;br /&gt;
 max_ws_frame_bytes = 131072&lt;br /&gt;
 broadcast_buffer = 1024&lt;br /&gt;
 event_persist_buffer = 16&lt;br /&gt;
&lt;br /&gt;
 [authorization]&lt;br /&gt;
 # ONLY these hex pubkeys can publish (write) events.&lt;br /&gt;
 # Reading is open to anyone in the world.&lt;br /&gt;
 pubkey_whitelist = [&lt;br /&gt;
   &amp;quot;REPLACE_WITH_HEX_OF_NPUB_1&amp;quot;,&lt;br /&gt;
   &amp;quot;REPLACE_WITH_HEX_OF_NPUB_2&amp;quot;,&lt;br /&gt;
   &amp;quot;REPLACE_WITH_HEX_OF_NPUB_3&amp;quot;,&lt;br /&gt;
 ]&lt;br /&gt;
&lt;br /&gt;
 [verified_users]&lt;br /&gt;
 mode = &amp;quot;disabled&amp;quot;&lt;br /&gt;
&lt;br /&gt;
 [retention]&lt;br /&gt;
 # Keep everything forever for a private relay. Tweak if you want.&lt;br /&gt;
 # max_events = 0&lt;br /&gt;
 # max_bytes = 0&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
Save with &amp;lt;code&amp;gt;Ctrl+O&amp;lt;/code&amp;gt;, Enter, then &amp;lt;code&amp;gt;Ctrl+X&amp;lt;/code&amp;gt; to exit nano.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;⚠ Important — read this twice, it&#039;s the #1 mistake&#039;&#039;&#039;:&lt;br /&gt;
* &#039;&#039;&#039;The whitelist is the HEX form of the pubkey, NEVER the npub form.&#039;&#039;&#039; Lowercase, exactly 64 characters, only digits 0-9 and letters a-f. Pasting &amp;lt;code&amp;gt;npub1abc...&amp;lt;/code&amp;gt; values here is a silent disaster — the relay starts fine and accepts connections fine, but every single EVENT you publish gets rejected with &amp;lt;code&amp;gt;OK accepted=false reason=&amp;quot;blocked: pubkey is not allowed to publish to this relay&amp;quot;&amp;lt;/code&amp;gt;, because no pubkey in the world will ever match those bech32 strings. You won&#039;t see this until you try to publish, and even then it&#039;s a confusing error that looks like &#039;&#039;you&#039;&#039; did something wrong.&lt;br /&gt;
* &#039;&#039;&#039;If you skipped Step 2, go back and do it now.&#039;&#039;&#039; Step 2 is specifically about converting npub → hex for this exact purpose. The conversion is deterministic — same key, just different encoding. &amp;lt;code&amp;gt;nak decode npub1...&amp;lt;/code&amp;gt; on your laptop prints the hex; or use &amp;lt;code&amp;gt;nostr-gen.html&amp;lt;/code&amp;gt; in the v4call repo (the public hex pubkey is shown right under the npub, with a copy button).&lt;br /&gt;
* &#039;&#039;&#039;Sanity check before saving&#039;&#039;&#039;: every entry between the quotes should be exactly 64 characters, all lowercase, all hex. Count if you have to. &amp;lt;code&amp;gt;npub1...&amp;lt;/code&amp;gt; values are roughly 63 characters and contain non-hex letters like &amp;lt;code&amp;gt;z&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;p&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;j&amp;lt;/code&amp;gt; — instant red flag.&lt;br /&gt;
* &#039;&#039;&#039;If you typo a hex character (63 chars, or a stray &amp;lt;code&amp;gt;g&amp;lt;/code&amp;gt;-&amp;lt;code&amp;gt;z&amp;lt;/code&amp;gt;), the relay refuses to start.&#039;&#039;&#039; Check &amp;lt;code&amp;gt;docker compose logs nostr-relay&amp;lt;/code&amp;gt; for the parse error. &#039;&#039;But pasting valid-looking npubs starts cleanly and silently rejects every publish&#039;&#039; — that&#039;s the bigger trap.&lt;br /&gt;
* &#039;&#039;&#039;For the second server, change &amp;lt;code&amp;gt;relay_url&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;name&amp;lt;/code&amp;gt; to the v4call values, but the whitelist stays the same.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Tip&#039;&#039;&#039;: nostr-rs-relay also supports paid-relay mode and NIP-42 AUTH gating. For &amp;quot;just whitelist 3 keys&amp;quot;, &amp;lt;code&amp;gt;pubkey_whitelist&amp;lt;/code&amp;gt; is the simplest mechanism. The relay will reject any incoming EVENT whose &amp;lt;code&amp;gt;pubkey&amp;lt;/code&amp;gt; field isn&#039;t in this list, with a clear NOTICE back to the client.&lt;br /&gt;
&lt;br /&gt;
== Step 11: Write the Caddy Config (Caddyfile) ==&lt;br /&gt;
&lt;br /&gt;
[https://caddyserver.com Caddy] is a reverse proxy that auto-fetches and auto-renews Let&#039;s Encrypt certificates. It&#039;s much simpler than nginx + certbot for a single-purpose box.&lt;br /&gt;
&lt;br /&gt;
Create the Caddyfile — &#039;&#039;&#039;use the correct domain for this server&#039;&#039;&#039; (hive-book.com on the first box, v4call.com on the second):&lt;br /&gt;
&lt;br /&gt;
 nano /opt/nostr-relay/Caddyfile&lt;br /&gt;
&lt;br /&gt;
Paste (for the first server):&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
 nostr.hive-book.com {&lt;br /&gt;
   # WebSocket reverse proxy to the relay container on port 8080.&lt;br /&gt;
   reverse_proxy nostr-relay:8080&lt;br /&gt;
&lt;br /&gt;
   # Friendly response if someone hits the URL in a normal browser.&lt;br /&gt;
   @browser {&lt;br /&gt;
     not header Connection *Upgrade*&lt;br /&gt;
     not path /&lt;br /&gt;
   }&lt;br /&gt;
&lt;br /&gt;
   # Optional: log access. Comment out to disable.&lt;br /&gt;
   log {&lt;br /&gt;
     output file /data/access.log&lt;br /&gt;
     format console&lt;br /&gt;
   }&lt;br /&gt;
 }&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
Save and exit.&lt;br /&gt;
&lt;br /&gt;
For the second box, the only change is the first line:&lt;br /&gt;
&lt;br /&gt;
 nostr.v4call.com {&lt;br /&gt;
   reverse_proxy nostr-relay:8080&lt;br /&gt;
   ...&lt;br /&gt;
 }&lt;br /&gt;
&lt;br /&gt;
Caddy will see this file, fetch a Let&#039;s Encrypt cert for the domain on first start, and auto-renew it forever. Zero manual cert work.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Tip&#039;&#039;&#039;: Caddy stores its cert state in &amp;lt;code&amp;gt;/data&amp;lt;/code&amp;gt; inside the container — we mount that to &amp;lt;code&amp;gt;./caddy_data&amp;lt;/code&amp;gt; on the host so the cert survives container restarts. Don&#039;t delete that folder unless you want Caddy to re-issue from scratch.&lt;br /&gt;
&lt;br /&gt;
== Step 12: Write the docker-compose.yml ==&lt;br /&gt;
&lt;br /&gt;
 nano /opt/nostr-relay/docker-compose.yml&lt;br /&gt;
&lt;br /&gt;
Paste:&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
 services:&lt;br /&gt;
   nostr-relay:&lt;br /&gt;
     image: scsibug/nostr-rs-relay:latest&lt;br /&gt;
     container_name: nostr-relay&lt;br /&gt;
     restart: unless-stopped&lt;br /&gt;
     volumes:&lt;br /&gt;
       - ./config.toml:/usr/src/app/config.toml:ro&lt;br /&gt;
       - ./data:/usr/src/app/db&lt;br /&gt;
     expose:&lt;br /&gt;
       - &amp;quot;8080&amp;quot;&lt;br /&gt;
     networks:&lt;br /&gt;
       - relaynet&lt;br /&gt;
&lt;br /&gt;
   caddy:&lt;br /&gt;
     image: caddy:2-alpine&lt;br /&gt;
     container_name: caddy&lt;br /&gt;
     restart: unless-stopped&lt;br /&gt;
     ports:&lt;br /&gt;
       - &amp;quot;80:80&amp;quot;&lt;br /&gt;
       - &amp;quot;443:443&amp;quot;&lt;br /&gt;
     volumes:&lt;br /&gt;
       - ./Caddyfile:/etc/caddy/Caddyfile:ro&lt;br /&gt;
       - ./caddy_data:/data&lt;br /&gt;
       - ./caddy_config:/config&lt;br /&gt;
     depends_on:&lt;br /&gt;
       - nostr-relay&lt;br /&gt;
     networks:&lt;br /&gt;
       - relaynet&lt;br /&gt;
&lt;br /&gt;
 networks:&lt;br /&gt;
   relaynet:&lt;br /&gt;
     driver: bridge&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
Save and exit.&lt;br /&gt;
&lt;br /&gt;
What this does:&lt;br /&gt;
* &#039;&#039;&#039;nostr-relay&#039;&#039;&#039; container runs the Rust relay, reads &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt;, persists its SQLite DB to &amp;lt;code&amp;gt;./data&amp;lt;/code&amp;gt; on the host. Not exposed to the public — only Caddy talks to it on the internal Docker network.&lt;br /&gt;
* &#039;&#039;&#039;caddy&#039;&#039;&#039; container holds the public ports (80, 443), terminates TLS, and reverse-proxies WebSocket traffic to the relay. Persists certificates to &amp;lt;code&amp;gt;./caddy_data&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Step 13: Start It Up ==&lt;br /&gt;
&lt;br /&gt;
From &amp;lt;code&amp;gt;/opt/nostr-relay&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
 docker compose pull&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
&lt;br /&gt;
Watch the logs for a minute:&lt;br /&gt;
&lt;br /&gt;
 docker compose logs -f&lt;br /&gt;
&lt;br /&gt;
You should see:&lt;br /&gt;
* nostr-relay: &amp;lt;code&amp;gt;relay starting on 0.0.0.0:8080&amp;lt;/code&amp;gt; (or similar)&lt;br /&gt;
* caddy: a line about obtaining a certificate for &amp;lt;code&amp;gt;nostr.hive-book.com&amp;lt;/code&amp;gt; via the ACME challenge, then a successful &amp;lt;code&amp;gt;certificate obtained&amp;lt;/code&amp;gt; line.&lt;br /&gt;
&lt;br /&gt;
Press &amp;lt;code&amp;gt;Ctrl+C&amp;lt;/code&amp;gt; to stop tailing logs. The containers keep running.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;⚠ If you see &amp;lt;code&amp;gt;ERROR r2d2: unable to open database file: /usr/src/app/db/nostr.db&amp;lt;/code&amp;gt;&#039;&#039;&#039; (looping every few seconds in the &amp;lt;code&amp;gt;nostr-relay&amp;lt;/code&amp;gt; logs):&lt;br /&gt;
&lt;br /&gt;
This is the most common first-run snag. The &amp;lt;code&amp;gt;scsibug/nostr-rs-relay&amp;lt;/code&amp;gt; container runs as a non-root user (UID 100) inside the container, but your &amp;lt;code&amp;gt;./data&amp;lt;/code&amp;gt; directory on the host was created by &amp;lt;code&amp;gt;root&amp;lt;/code&amp;gt; in Step 9 — so the container user can&#039;t write to it. SQLite refuses to create the DB file, the relay loops, and Caddy proxies start returning 502.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Quick fix (just unblock me)&#039;&#039;&#039; — works on any image, any version, fine for a single-purpose box where the data dir holds only public Nostr events:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 docker compose down&lt;br /&gt;
 chmod -R 777 /opt/nostr-relay/data&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
 docker compose logs -f nostr-relay&lt;br /&gt;
&lt;br /&gt;
You should now see:&lt;br /&gt;
&lt;br /&gt;
 INFO nostr_rs_relay::repo::sqlite: Built a connection pool &amp;quot;writer&amp;quot; (min=0, max=2)&lt;br /&gt;
 INFO nostr_rs_relay::repo::sqlite: Built a connection pool &amp;quot;reader&amp;quot; (min=0, max=8)&lt;br /&gt;
&lt;br /&gt;
…and &#039;&#039;&#039;no more&#039;&#039;&#039; &amp;lt;code&amp;gt;r2d2: unable to open database file&amp;lt;/code&amp;gt; errors. Press &amp;lt;code&amp;gt;Ctrl+C&amp;lt;/code&amp;gt; to stop tailing.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Tighter fix (chown to the correct UID)&#039;&#039;&#039; — recommended once you&#039;ve confirmed the relay works. The image uses an &amp;lt;code&amp;gt;appuser&amp;lt;/code&amp;gt; created by the base image, but the exact UID can change between image releases — &#039;&#039;&#039;don&#039;t trust a hardcoded number from a guide, ask the image&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
 # Find out what UID the container actually runs as:&lt;br /&gt;
 docker run --rm scsibug/nostr-rs-relay:latest id&lt;br /&gt;
 # Example output:  uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)&lt;br /&gt;
&lt;br /&gt;
Take the UID and GID from that output (let&#039;s say both are 1000) and apply them to the data directory:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/nostr-relay&lt;br /&gt;
 docker compose down&lt;br /&gt;
 chmod -R 755 /opt/nostr-relay/data       # back to sensible perms&lt;br /&gt;
 chown -R 1000:1000 /opt/nostr-relay/data  # use YOUR numbers from above&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
 docker compose logs -f nostr-relay&lt;br /&gt;
&lt;br /&gt;
If logs are clean, the chown stuck and you&#039;re now running with proper UID alignment instead of world-writable.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Why does this happen?&#039;&#039;&#039; The relay&#039;s image creates a non-root &amp;lt;code&amp;gt;appuser&amp;lt;/code&amp;gt; and switches to it before running — good security hygiene (the container can&#039;t run as root even if exploited). The cost is that any host-mounted volume needs to be writable by &#039;&#039;that exact UID&#039;&#039;. The host&#039;s &amp;lt;code&amp;gt;./data&amp;lt;/code&amp;gt; folder was created by &amp;lt;code&amp;gt;root&amp;lt;/code&amp;gt; in Step 9, so by default the container user can&#039;t write to it. Same pattern v4call uses (UID 1000 there) — Docker bind-mounts always need host-side permissions matching the container-side user.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Will this break on image updates?&#039;&#039;&#039; Usually not — the UID is baked into the image and stays stable across patch releases. But if you ever &amp;lt;code&amp;gt;docker compose pull&amp;lt;/code&amp;gt; a new image and the relay starts erroring on the DB again, re-run &amp;lt;code&amp;gt;docker run --rm scsibug/nostr-rs-relay:latest id&amp;lt;/code&amp;gt; to check whether the UID changed, and re-chown if it did.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Why not just always use &amp;lt;code&amp;gt;chmod 777&amp;lt;/code&amp;gt;?&#039;&#039;&#039; For a private 3-key relay storing public events, it&#039;s genuinely fine — the data is signed-and-public by design and the box is single-purpose. The &amp;lt;code&amp;gt;chown&amp;lt;/code&amp;gt; approach is just hygiene that pays off if the box ever ends up serving more than one thing.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Ignore the &amp;lt;code&amp;gt;502&amp;lt;/code&amp;gt; lines from random IPs&#039;&#039;&#039; (e.g. &amp;lt;code&amp;gt;monitor-telegram-clone-realtime/1.0&amp;lt;/code&amp;gt;, IPs you don&#039;t recognise). Within minutes of a fresh domain getting a Let&#039;s Encrypt cert, internet-wide scanners hit it. They get a 502 while the relay is broken and a normal HTTP response (or WebSocket upgrade) once it&#039;s working. Not an attack, not anything to fix — just background noise of the public internet. &amp;lt;code&amp;gt;ufw&amp;lt;/code&amp;gt; + the whitelist + Caddy&#039;s TLS handshake are doing their jobs.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;⚠ If Caddy can&#039;t get a certificate&#039;&#039;&#039;:&lt;br /&gt;
* DNS for the domain isn&#039;t pointing to this VPS yet → wait, run &amp;lt;code&amp;gt;nslookup nostr.hive-book.com&amp;lt;/code&amp;gt; from a different machine, confirm it resolves to your IP.&lt;br /&gt;
* Port 80 is closed → re-check Step 8.&lt;br /&gt;
* Caddy logs will tell you the exact ACME failure reason. Read them.&lt;br /&gt;
&lt;br /&gt;
== Step 14: Test the Relay ==&lt;br /&gt;
&lt;br /&gt;
;Test 1 — Is the WebSocket reachable?&lt;br /&gt;
:You need a tiny WebSocket client on your laptop. Pick whichever is easiest for your OS:&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Option 1A — websocat prebuilt binary (any Linux, no compilation)&#039;&#039;&#039;&lt;br /&gt;
:Skip &amp;lt;code&amp;gt;cargo install websocat&amp;lt;/code&amp;gt; on Ubuntu 24.04 — its apt-shipped Rust 1.75 is too old to compile current websocat, and you&#039;ll waste 10 minutes downloading half of crates.io before hitting an &amp;lt;code&amp;gt;edition2024&amp;lt;/code&amp;gt; error. Grab the prebuilt static binary instead:&lt;br /&gt;
: &amp;lt;code&amp;gt;sudo wget -O /usr/local/bin/websocat https://github.com/vi/websocat/releases/latest/download/websocat.x86_64-unknown-linux-musl&amp;lt;/code&amp;gt;&lt;br /&gt;
: &amp;lt;code&amp;gt;sudo chmod +x /usr/local/bin/websocat&amp;lt;/code&amp;gt;&lt;br /&gt;
: &amp;lt;code&amp;gt;websocat --version&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Option 1B — wscat (Node.js, simpler if you already have npm)&#039;&#039;&#039;&lt;br /&gt;
: &amp;lt;code&amp;gt;sudo apt install -y npm &amp;amp;&amp;amp; sudo npm install -g wscat&amp;lt;/code&amp;gt;&lt;br /&gt;
:Then use &amp;lt;code&amp;gt;wscat -c wss://...&amp;lt;/code&amp;gt; instead of &amp;lt;code&amp;gt;websocat wss://...&amp;lt;/code&amp;gt; in the steps below. wscat prints a &amp;quot;Connected&amp;quot; banner so it&#039;s friendlier than websocat for first-time testing.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Option 1C — nak (Go, the Nostr-native option, also handy for Steps 1 + 2)&#039;&#039;&#039;&lt;br /&gt;
:Install Go from apt and use it to build nak:&lt;br /&gt;
: &amp;lt;code&amp;gt;sudo apt install -y golang-go&amp;lt;/code&amp;gt;&lt;br /&gt;
: &amp;lt;code&amp;gt;go install github.com/fiatjaf/nak@latest&amp;lt;/code&amp;gt;&lt;br /&gt;
: &amp;lt;code&amp;gt;echo &#039;export PATH=$PATH:$HOME/go/bin&#039; &amp;gt;&amp;gt; ~/.bashrc &amp;amp;&amp;amp; source ~/.bashrc&amp;lt;/code&amp;gt;&lt;br /&gt;
: &amp;lt;code&amp;gt;nak --help&amp;lt;/code&amp;gt;      # confirm it&#039;s on PATH&lt;br /&gt;
:Then skip websocat/wscat entirely:&lt;br /&gt;
: &amp;lt;code&amp;gt;nak req -k 1 wss://nostr.hive-book.com&amp;lt;/code&amp;gt;&lt;br /&gt;
:Prints any matching events and exits. Done. nak also does npub/hex conversion (&amp;lt;code&amp;gt;nak decode npub1...&amp;lt;/code&amp;gt;) and signing (&amp;lt;code&amp;gt;nak event --sec ...&amp;lt;/code&amp;gt;) — see Step 1 and the bonus test at the end of Step 14.&lt;br /&gt;
:&#039;&#039;&#039;💡 Tip&#039;&#039;&#039;: if &amp;lt;code&amp;gt;nak --help&amp;lt;/code&amp;gt; says &amp;quot;command not found&amp;quot; after install, your shell hasn&#039;t reloaded PATH. Either open a new terminal or run &amp;lt;code&amp;gt;export PATH=$PATH:$HOME/go/bin&amp;lt;/code&amp;gt; in the current one.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Option 1D — Mac users&#039;&#039;&#039;&lt;br /&gt;
: &amp;lt;code&amp;gt;brew install websocat&amp;lt;/code&amp;gt; just works.&lt;br /&gt;
&lt;br /&gt;
Once you have one of the above, connect:&lt;br /&gt;
&lt;br /&gt;
 websocat -v wss://nostr.hive-book.com/&lt;br /&gt;
   # or:  wscat -c wss://nostr.hive-book.com/&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;⚠ websocat&#039;s silent connection trips up first-time users.&#039;&#039;&#039; Without the &amp;lt;code&amp;gt;-v&amp;lt;/code&amp;gt; flag, websocat opens the WebSocket and just sits there with a blank cursor — no &amp;quot;Connected!&amp;quot; banner, no prompt, nothing. That&#039;s &#039;&#039;&#039;not&#039;&#039;&#039; a hang, that&#039;s a working connection waiting for you to type. The &amp;lt;code&amp;gt;-v&amp;lt;/code&amp;gt; flag in the command above prints lifecycle messages like &amp;lt;code&amp;gt;get_ws_client_peer&amp;lt;/code&amp;gt; so you can see it actually connected. (wscat is friendlier — it prints &amp;quot;Connected (press CTRL+C to quit)&amp;quot;.)&lt;br /&gt;
&lt;br /&gt;
Once connected (silent or banner, doesn&#039;t matter), paste this exact line:&lt;br /&gt;
&lt;br /&gt;
 [&amp;quot;REQ&amp;quot;,&amp;quot;test&amp;quot;,{&amp;quot;kinds&amp;quot;:[1],&amp;quot;limit&amp;quot;:1}]&lt;br /&gt;
&lt;br /&gt;
Press Enter. The relay will respond with &amp;lt;code&amp;gt;[&amp;quot;EOSE&amp;quot;,&amp;quot;test&amp;quot;]&amp;lt;/code&amp;gt; (end of stored events) — meaning it&#039;s alive and there&#039;s nothing stored yet. Press &amp;lt;code&amp;gt;Ctrl+C&amp;lt;/code&amp;gt; to disconnect.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 If you got nothing back&#039;&#039;&#039; after typing the REQ — there&#039;s actually a stored event somewhere on this relay (so the response would be a JSON event line then EOSE), or the relay is alive but rejecting your REQ for some reason. Run with &amp;lt;code&amp;gt;nak req -k 1 wss://...&amp;lt;/code&amp;gt; instead to get a clearer view of what&#039;s happening. Or check &amp;lt;code&amp;gt;docker compose logs nostr-relay&amp;lt;/code&amp;gt; on the server side for what it received.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 &amp;lt;code&amp;gt;[&amp;quot;NOTICE&amp;quot;,&amp;quot;could not parse command&amp;quot;]&amp;lt;/code&amp;gt; is also a &amp;quot;working&amp;quot; signal.&#039;&#039;&#039; If you accidentally hit Enter on a blank line, type plain English, or paste anything that isn&#039;t valid Nostr JSON, the relay replies with a NOTICE rejection. That&#039;s &#039;&#039;&#039;good news&#039;&#039;&#039; — it proves the relay parsed your input and pushed back. A truly broken relay would either disconnect or stay silent. So if you mistype the REQ command and see a NOTICE come back, just paste the correct JSON and try again.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 You don&#039;t actually have to type anything to confirm Test 1 passed.&#039;&#039;&#039; The verbose output from &amp;lt;code&amp;gt;websocat -v&amp;lt;/code&amp;gt; already tells you everything you need:&lt;br /&gt;
&lt;br /&gt;
 [INFO  websocat::net_peer] Connected to TCP &amp;lt;vps-ip&amp;gt;:443&lt;br /&gt;
 [INFO  websocat::ws_client_peer] Connected to ws, response headers: Headers { ...&lt;br /&gt;
   Server: Caddy&lt;br /&gt;
   Upgrade: websocket&lt;br /&gt;
 }&lt;br /&gt;
&lt;br /&gt;
The TCP-on-443 line + the &amp;lt;code&amp;gt;Upgrade: websocket&amp;lt;/code&amp;gt; header + &amp;lt;code&amp;gt;Server: Caddy&amp;lt;/code&amp;gt; together prove: DNS resolved, TLS handshake completed, Caddy accepted the connection, the WebSocket upgrade succeeded, the relay is on the other end. If you see those, you can &amp;lt;code&amp;gt;Ctrl+C&amp;lt;/code&amp;gt; right there — Test 1 is already a pass. Typing the REQ is just an extra &amp;quot;and it speaks Nostr&amp;quot; sanity check on top.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Tip&#039;&#039;&#039;: you can also test purely from a browser. Open your client of choice (e.g. [https://nostrudel.ninja nostrudel.ninja]) and add &amp;lt;code&amp;gt;wss://nostr.hive-book.com&amp;lt;/code&amp;gt; as a custom relay. If it connects without TLS errors, the relay is reachable. The browser console (F12) will show the WebSocket open/close lifecycle.&lt;br /&gt;
&lt;br /&gt;
;Test 2 — Whitelist actually rejects strangers&lt;br /&gt;
:From any Nostr client logged in with a key that is &#039;&#039;&#039;NOT&#039;&#039;&#039; in your whitelist, try posting a note and add &amp;lt;code&amp;gt;wss://nostr.hive-book.com/&amp;lt;/code&amp;gt; as a relay. The relay should respond with an OK=false NOTICE explaining the pubkey isn&#039;t allowed. Look at &amp;lt;code&amp;gt;docker compose logs nostr-relay&amp;lt;/code&amp;gt; — you&#039;ll see the rejection logged.&lt;br /&gt;
&lt;br /&gt;
;Test 3 — Whitelisted key CAN publish&lt;br /&gt;
:Use a Nostr client logged in with one of your 3 whitelisted keys. Add the relay. Post a short note. Run Test 1&#039;s REQ command again — this time you should see the event come back.&lt;br /&gt;
&lt;br /&gt;
If all three tests pass, your first relay is live. 🎉&lt;br /&gt;
&lt;br /&gt;
;Bonus test with &amp;lt;code&amp;gt;nak&amp;lt;/code&amp;gt;&lt;br /&gt;
:If you have nak installed, you can publish straight from the command line:&lt;br /&gt;
: &amp;lt;code&amp;gt;nak event --sec YOUR_NSEC_HEX -c &amp;quot;hello private relay&amp;quot; wss://nostr.hive-book.com&amp;lt;/code&amp;gt;&lt;br /&gt;
:And query:&lt;br /&gt;
: &amp;lt;code&amp;gt;nak req -k 1 wss://nostr.hive-book.com&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 15: Repeat for the Second Server ==&lt;br /&gt;
&lt;br /&gt;
Log out, ssh into the v4call box (&amp;lt;code&amp;gt;ssh root@IP_B&amp;lt;/code&amp;gt;), and repeat Steps 6 through 14 with these tweaks:&lt;br /&gt;
&lt;br /&gt;
* Step 10 — &amp;lt;code&amp;gt;relay_url&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;name&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; use the v4call domain.&lt;br /&gt;
* Step 11 — first line of the Caddyfile is &amp;lt;code&amp;gt;nostr.v4call.com {&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
The 3-key whitelist stays identical on both boxes — that&#039;s the whole point: same 3 keys can publish to either relay, and you have geographic / provider redundancy in case one box has an issue.&lt;br /&gt;
&lt;br /&gt;
When you&#039;re done, both &amp;lt;code&amp;gt;wss://nostr.hive-book.com&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;wss://nostr.v4call.com&amp;lt;/code&amp;gt; answer to the same 3 keys.&lt;br /&gt;
&lt;br /&gt;
== Updating the Whitelist ==&lt;br /&gt;
&lt;br /&gt;
To add or remove a key:&lt;br /&gt;
&lt;br /&gt;
# SSH into the box.&lt;br /&gt;
# &amp;lt;code&amp;gt;nano /opt/nostr-relay/config.toml&amp;lt;/code&amp;gt; and edit the &amp;lt;code&amp;gt;pubkey_whitelist&amp;lt;/code&amp;gt; array.&lt;br /&gt;
# &amp;lt;code&amp;gt;cd /opt/nostr-relay &amp;amp;&amp;amp; docker compose restart nostr-relay&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The relay restarts in a couple of seconds. Caddy keeps running — no certificate work needed for a config tweak.&lt;br /&gt;
&lt;br /&gt;
Repeat on the second box if you want both relays in sync.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;💡 Tip&#039;&#039;&#039;: make this a habit — keep the 3 hex pubkeys in a notes file outside the server too. When you rotate a key, update the notes file &#039;&#039;and&#039;&#039; both relays.&lt;br /&gt;
&lt;br /&gt;
== Common Problems and Fixes ==&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Caddy: &amp;quot;no domains qualify for managed certificates&amp;quot;&#039;&#039;&#039;&lt;br /&gt;
:DNS not pointing to the box yet, or pointing to the &#039;&#039;wrong&#039;&#039; box. Run &amp;lt;code&amp;gt;nslookup yourdomain&amp;lt;/code&amp;gt; from a third location. If it shows the wrong IP, fix DNS and wait 5 minutes.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Caddy: &amp;quot;context deadline exceeded&amp;quot; / &amp;quot;connection refused&amp;quot; on ACME challenge&#039;&#039;&#039;&lt;br /&gt;
:Port 80 is firewalled. Re-check &amp;lt;code&amp;gt;ufw status&amp;lt;/code&amp;gt;. Also make sure no other process is on port 80 (&amp;lt;code&amp;gt;ss -tlnp | grep &#039;:80&#039;&amp;lt;/code&amp;gt;).&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Relay logs: &amp;quot;invalid pubkey in whitelist&amp;quot;&#039;&#039;&#039;&lt;br /&gt;
:Your hex string isn&#039;t 64 lowercase hex characters. Common mistake: pasting the npub instead of the hex; or leaving in a stray space; or accidentally pasting an nsec. Reconvert from npub → hex (Step 2) and paste again.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Every publish gets &amp;lt;code&amp;gt;OK accepted=false reason=&amp;quot;blocked: pubkey is not allowed to publish to this relay&amp;quot;&amp;lt;/code&amp;gt; even though I added my key&#039;&#039;&#039;&lt;br /&gt;
:&#039;&#039;&#039;99% of the time&#039;&#039;&#039;: you pasted &amp;lt;code&amp;gt;npub1...&amp;lt;/code&amp;gt; values into &amp;lt;code&amp;gt;pubkey_whitelist&amp;lt;/code&amp;gt; instead of the 64-char hex form. The relay starts cleanly with npubs (it&#039;s just a string list to TOML) but &#039;&#039;no&#039;&#039; real pubkey ever matches a bech32 string, so every publish silently rejects.&lt;br /&gt;
:Fix: convert each npub to hex on your laptop —&lt;br /&gt;
: &amp;lt;code&amp;gt;nak decode npub1abc... | jq -r .pubkey&amp;lt;/code&amp;gt;&lt;br /&gt;
:or open &amp;lt;code&amp;gt;nostr-gen.html&amp;lt;/code&amp;gt; from the v4call repo (the hex pubkey is now displayed under the npub with a copy button). Replace every entry in &amp;lt;code&amp;gt;pubkey_whitelist&amp;lt;/code&amp;gt; with the hex form, then:&lt;br /&gt;
: &amp;lt;code&amp;gt;cd /opt/nostr-relay &amp;amp;&amp;amp; docker compose restart nostr-relay&amp;lt;/code&amp;gt;&lt;br /&gt;
:Watch &amp;lt;code&amp;gt;docker compose logs -f nostr-relay&amp;lt;/code&amp;gt; while you re-publish from your client — the rejection line shows exactly which hex pubkey the relay is checking against; you can compare it character-by-character to &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt;. If they don&#039;t match, the wrong key got loaded into your client (or the wrong hex into the config).&lt;br /&gt;
:Edge cases that cause the same error: case mismatch (whitelist must be lowercase), a stray trailing space inside the quotes, or you edited &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt; on one box but not the other. Always check both.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Whitelisted client gets rejected anyway&#039;&#039;&#039;&lt;br /&gt;
:Almost always: the client signed the event with a different key than you put on the whitelist (e.g. you converted the wrong npub, or you have multiple identities in your client). In the client, verify which key is actively signing, get its npub, convert again, compare to &amp;lt;code&amp;gt;config.toml&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;&amp;lt;code&amp;gt;websocat wss://...&amp;lt;/code&amp;gt; says &amp;quot;TLS handshake error&amp;quot;&#039;&#039;&#039;&lt;br /&gt;
:Caddy hasn&#039;t finished provisioning the certificate yet. Wait 30 seconds and try again. If it persists for &amp;gt;5 minutes, check &amp;lt;code&amp;gt;docker compose logs caddy&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;&amp;lt;code&amp;gt;cargo install websocat&amp;lt;/code&amp;gt; fails with &amp;lt;code&amp;gt;feature &#039;edition2024&#039; is required&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
:Ubuntu 24.04&#039;s apt cargo (1.75) is too old to compile current websocat. Don&#039;t try to upgrade Rust just for this — grab the prebuilt static binary instead (Test 1, Option 1A in Step 14). Or use &amp;lt;code&amp;gt;wscat&amp;lt;/code&amp;gt; via npm. Or skip ahead to &amp;lt;code&amp;gt;nak&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;&amp;lt;code&amp;gt;websocat: command not found&amp;lt;/code&amp;gt; after a failed install&#039;&#039;&#039;&lt;br /&gt;
:The cargo install errored before it could place the binary. Use Option 1A (prebuilt binary to &amp;lt;code&amp;gt;/usr/local/bin&amp;lt;/code&amp;gt;) instead.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;&amp;lt;code&amp;gt;r2d2: unable to open database file&amp;lt;/code&amp;gt; looping in nostr-relay logs (and Caddy returning 502)&#039;&#039;&#039;&lt;br /&gt;
:UID mismatch on the bind-mounted data directory. The container runs as a non-root &amp;lt;code&amp;gt;appuser&amp;lt;/code&amp;gt;; your &amp;lt;code&amp;gt;./data&amp;lt;/code&amp;gt; folder is owned by root. Quick fix: &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; chmod -R 777 /opt/nostr-relay/data &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt; (fine for a single-purpose box). Tighter fix: find the image&#039;s actual UID via &amp;lt;code&amp;gt;docker run --rm scsibug/nostr-rs-relay:latest id&amp;lt;/code&amp;gt;, then &amp;lt;code&amp;gt;chown -R UID:GID /opt/nostr-relay/data&amp;lt;/code&amp;gt;. See the inline note at the end of Step 13 for the full explanation.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Container keeps restarting&#039;&#039;&#039;&lt;br /&gt;
:&amp;lt;code&amp;gt;docker compose logs --tail=50 nostr-relay&amp;lt;/code&amp;gt; — almost always a config.toml syntax error (missing quote, unclosed bracket). Fix and &amp;lt;code&amp;gt;docker compose up -d&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
;&#039;&#039;&#039;Disk filling up over time&#039;&#039;&#039;&lt;br /&gt;
:Check &amp;lt;code&amp;gt;du -sh /opt/nostr-relay/data&amp;lt;/code&amp;gt;. If you&#039;re storing kind 1 notes from 3 active users and never deleting, it grows slowly (megabytes per year). If it grows fast, something is wrong (a whitelisted key is firehosing) — investigate the events table.&lt;br /&gt;
&lt;br /&gt;
== Tips for Nostr Noobs ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;npub vs nsec&#039;&#039;&#039;: npub starts with &amp;lt;code&amp;gt;npub1&amp;lt;/code&amp;gt;, is your public identity, share freely. nsec starts with &amp;lt;code&amp;gt;nsec1&amp;lt;/code&amp;gt;, is your private signing key, &#039;&#039;&#039;never paste it anywhere except your own client&#039;&#039;&#039; (and ideally only via a hardware-isolated tool like a browser extension or a hardware signer). If you ever paste an nsec into a website you don&#039;t fully trust, treat that key as compromised and rotate.&lt;br /&gt;
* &#039;&#039;&#039;There is no &amp;quot;delete&amp;quot; on Nostr&#039;&#039;&#039; — events are signed objects relays store. NIP-09 has a &amp;quot;delete request&amp;quot; event, but relays may or may not honour it, and other relays will still have copies. Treat anything you publish as permanent.&lt;br /&gt;
* &#039;&#039;&#039;Public read, private write is a useful pattern&#039;&#039;&#039; — what you&#039;ve built. People can &#039;&#039;see&#039;&#039; what your 3 keys publish (great for credibility, transparency, archive value) but can&#039;t pollute your relay with their own stuff.&lt;br /&gt;
* &#039;&#039;&#039;Federation note for v4call operators&#039;&#039;&#039;: the v4call NOSTR-DESIGN-NOTES.md (in the v4call repo) describes a server-level identity (per-server keypair + &amp;lt;code&amp;gt;kind:30078&amp;lt;/code&amp;gt; announce events). One natural pattern: the third whitelisted key on your relay is your v4call &#039;&#039;server&#039;s&#039;&#039; Nostr identity, while the other two are your personal + a partner&#039;s. The relay then doubles as v4call federation discovery infrastructure.&lt;br /&gt;
* &#039;&#039;&#039;Backups&#039;&#039;&#039;: back up &amp;lt;code&amp;gt;/opt/nostr-relay/data&amp;lt;/code&amp;gt; (the SQLite DB). The Caddy cert volume can be re-issued from Let&#039;s Encrypt at any time, so it&#039;s less critical, but no harm in including it.&lt;br /&gt;
* &#039;&#039;&#039;Costs&#039;&#039;&#039;: $6/month per Vultr box × 2 = $12/month. Domain renewal is the only other cost. No platform fees, no per-event charges.&lt;br /&gt;
* &#039;&#039;&#039;Want to learn more?&#039;&#039;&#039; Read [https://github.com/nostr-protocol/nips the NIPs] (Nostr Implementation Possibilities) — they&#039;re short, readable specs. NIP-01 is the protocol core. NIP-11 is relay information documents (worth implementing later via &amp;lt;code&amp;gt;relay_info&amp;lt;/code&amp;gt; in config.toml). NIP-42 is AUTH (a more sophisticated alternative to a flat whitelist).&lt;br /&gt;
* &#039;&#039;&#039;Test client&#039;&#039;&#039;: [https://nostrudel.ninja nostrudel.ninja] is a clean web client that lets you add custom relays per account. Great for poking at your own relay without committing to a heavy app.&lt;br /&gt;
&lt;br /&gt;
You now have two private, whitelisted Nostr relays. Welcome to running your own Nostr infrastructure. 🌿&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=753</id>
		<title>Hive Blockchain Create Custom Coins</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=753"/>
		<updated>2026-05-08T09:37:22Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_CC0}}&lt;br /&gt;
= v4call — How to Create Your Own Custom Token (e.g. CNOOBS) on Hive Engine =&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
F&lt;br /&gt;
This guide walks you through creating a custom Hive Engine token (example: &#039;&#039;&#039;CNOOBS&#039;&#039;&#039;) in under 10 minutes. No coding required.&lt;br /&gt;
&lt;br /&gt;
These tokens power the custom communication economy in v4call: you can set rates like &amp;quot;1 CNOOBS = 1 text message&amp;quot; or &amp;quot;10 CNOOBS = 1 hour video call&amp;quot;, gift them to friends/family, or let blocked users bypass your blocklist if they hold enough of your token.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why create your own token?&#039;&#039;&#039;&lt;br /&gt;
* Full control over supply and distribution.&lt;br /&gt;
* Real utility inside v4call (and any other Hive dApp that supports Hive Engine tokens).&lt;br /&gt;
* Scarcity you decide — perfect for personal or community communication economies.&lt;br /&gt;
* Transferable, tradable, and giftable via Hive Keychain or TribalDex.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Cost&#039;&#039;&#039;: Approximately 100 BEE (Hive Engine&#039;s utility token). BEE price fluctuates — check current value on TribalDex or PeakD. This is a one-time creation fee.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Source&#039;&#039;&#039;: Based on the official Hive Engine / TribalDex interface (as of 2026).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;End result&#039;&#039;&#039;: A live custom token (e.g. CNOOBS) that appears in users&#039; Hive Keychain wallets and can be used in your v4call rates post.&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
* [[#What_You_Need|1 What You Need]]&lt;br /&gt;
* [[#Step_1:_Get_Some_BEE_Tokens|2 Step 1: Get Some BEE Tokens]]&lt;br /&gt;
* [[#Step_2:_Log_In_to_TribalDex|3 Step 2: Log In to TribalDex]]&lt;br /&gt;
* [[#Step_3:_Create_Your_Token|4 Step 3: Create Your Token]]&lt;br /&gt;
* [[#Step_4:_Issue_Tokens_to_Yourself|5 Step 4: Issue Tokens to Yourself]]&lt;br /&gt;
* [[#Step_5:_Optional_-_Add_Metadata_(Logo_Description)|6 Step 5: Optional - Add Metadata (Logo &amp;amp; Description)]]&lt;br /&gt;
* [[#Step_6:_Distribute_Your_Tokens|7 Step 6: Distribute Your Tokens]]&lt;br /&gt;
* [[#Using_Your_Token_in_v4call|8 Using Your Token in v4call]]&lt;br /&gt;
* [[#Common_Problems_and_Fixes|9 Common Problems and Fixes]]&lt;br /&gt;
* [[#Quick_Reference|10 Quick Reference]]&lt;br /&gt;
&lt;br /&gt;
== What You Need ==&lt;br /&gt;
* A Hive account (e.g. @noblemage) with Hive Keychain installed and the &#039;&#039;&#039;active key&#039;&#039;&#039; available.&lt;br /&gt;
* Some BEE tokens (≈100 BEE for creation fee).&lt;br /&gt;
* A web browser.&lt;br /&gt;
&lt;br /&gt;
You do &#039;&#039;&#039;not&#039;&#039;&#039; need to run a node or write code.&lt;br /&gt;
&lt;br /&gt;
== Step 1: Get Some BEE Tokens ==&lt;br /&gt;
BEE is the &amp;quot;gas&amp;quot; token for Hive Engine actions.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com tribaldex.com] or any Hive Engine market (e.g. via PeakD wallet).&lt;br /&gt;
# Swap or buy BEE using HIVE or HBD (very easy with Keychain).&lt;br /&gt;
# Alternative: Many users get small amounts of BEE from community airdrops or by swapping on the built-in market.&lt;br /&gt;
&lt;br /&gt;
Make sure you have at least 110 BEE to cover the fee plus a small buffer.&lt;br /&gt;
&lt;br /&gt;
== Step 2: Log In to TribalDex ==&lt;br /&gt;
# Visit [https://tribaldex.com/tokens/create https://tribaldex.com/tokens/create]&lt;br /&gt;
# Click the login button at the top.&lt;br /&gt;
# Hive Keychain will pop up — approve the login with your &#039;&#039;&#039;active key&#039;&#039;&#039; (or posting key in some cases, but active is safest for token actions).&lt;br /&gt;
&lt;br /&gt;
You are now logged in as your Hive account.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Create Your Token ==&lt;br /&gt;
On the token creation form, fill in the following fields carefully. Most cannot be changed later.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Symbol&#039;&#039;&#039; — e.g. &#039;&#039;&#039;CNOOBS&#039;&#039;&#039; (uppercase, 1–10 characters, unique)&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; — e.g. &#039;&#039;&#039;Cnoobs Coins&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Max Supply&#039;&#039;&#039; — Choose a number (e.g. 1,000,000). This is the absolute maximum that can ever exist. You can issue less.&lt;br /&gt;
* &#039;&#039;&#039;Precision&#039;&#039;&#039; — Usually &#039;&#039;&#039;3&#039;&#039;&#039; (allows 0.001 precision). You can only increase this later, not decrease.&lt;br /&gt;
* &#039;&#039;&#039;Website&#039;&#039;&#039; (optional) — Link to your profile or v4call server (e.g. https://call.yourdomain.com)&lt;br /&gt;
* &#039;&#039;&#039;Description&#039;&#039;&#039; (optional) — Short text like &amp;quot;Personal communication token for v4call — used for messages and calls with @cnoobz&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Double-check everything — the symbol especially cannot be changed.&lt;br /&gt;
&lt;br /&gt;
Click &#039;&#039;&#039;Create Token&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Hive Keychain will ask you to approve a custom_json transaction. Confirm it.&lt;br /&gt;
&lt;br /&gt;
Wait 3–10 seconds for confirmation on the blockchain.&lt;br /&gt;
&lt;br /&gt;
Success message should appear: your token is now created!&lt;br /&gt;
&lt;br /&gt;
== Step 4: Issue Tokens to Yourself ==&lt;br /&gt;
After creation, you usually start with zero balance.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com/tokens/manage https://tribaldex.com/tokens/manage]&lt;br /&gt;
# Find your new token (CNOOBS) in the list.&lt;br /&gt;
# Click the &#039;&#039;&#039;Issue&#039;&#039;&#039; button.&lt;br /&gt;
# Enter the amount you want to issue to yourself (e.g. 10000).&lt;br /&gt;
# Confirm with Keychain.&lt;br /&gt;
&lt;br /&gt;
You now hold the full issued supply.&lt;br /&gt;
&lt;br /&gt;
== Step 5: Optional — Add Metadata (Logo &amp;amp; Description) ==&lt;br /&gt;
# Still on the manage page, click the edit icon for your token.&lt;br /&gt;
# Upload a square logo image (recommended 200x200 px or larger).&lt;br /&gt;
# Improve the description and website link.&lt;br /&gt;
# Save changes (another small Keychain transaction).&lt;br /&gt;
&lt;br /&gt;
This makes your token look professional when users view it in wallets or markets.&lt;br /&gt;
&lt;br /&gt;
== Step 6: Distribute Your Tokens ==&lt;br /&gt;
* Send to friends/family via Keychain → &amp;quot;Tokens&amp;quot; tab → Transfer.&lt;br /&gt;
* Airdrop to your community.&lt;br /&gt;
* Use in v4call rates (see below).&lt;br /&gt;
* List on TribalDex market if you want people to buy/sell them.&lt;br /&gt;
&lt;br /&gt;
Tokens are fully transferable and appear instantly in recipients&#039; Hive Keychain wallets.&lt;br /&gt;
&lt;br /&gt;
== Using Your Token in v4call ==&lt;br /&gt;
Once you have your token:&lt;br /&gt;
&lt;br /&gt;
# Go to your v4call server → /rate-editor.html&lt;br /&gt;
# In the rate editor (V2 format), create a new list like [LIST:token-holders]&lt;br /&gt;
# Set TOKEN:CNOOBS&lt;br /&gt;
# Define rates, e.g.:&lt;br /&gt;
** TEXT:1 CNOOBS&lt;br /&gt;
** VOICE:RING:5 CNOOBS;CONNECT:10 CNOOBS;RATE:20 CNOOBS/hr&lt;br /&gt;
* Your server will automatically verify balances using public Hive Engine RPC when someone tries to call or message you.&lt;br /&gt;
&lt;br /&gt;
Blocked users can bypass your blocklist if you set ALLOW-IF-TOKEN:CNOOBS and they hold enough.&lt;br /&gt;
&lt;br /&gt;
== Exchanging tokens on an exchange ==&lt;br /&gt;
Its pretty simple - you can exchange with hive-engine.com&lt;br /&gt;
* https://hive-engine.com/trade/CNOOBS&lt;br /&gt;
&amp;lt;code&amp;gt;https://hive-engine.com/trade/&amp;lt;YOUR_TOKEN&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Common Problems and Fixes ==&lt;br /&gt;
=== &amp;quot;Not enough BEE&amp;quot; error ===&lt;br /&gt;
Buy or swap more BEE on TribalDex.&lt;br /&gt;
&lt;br /&gt;
=== Token symbol already taken ===&lt;br /&gt;
Choose a different symbol (add numbers or make it longer, e.g. CNOOBS2).&lt;br /&gt;
&lt;br /&gt;
=== Transaction fails ===&lt;br /&gt;
Make sure you are using the &#039;&#039;&#039;active key&#039;&#039;&#039; in Keychain for token creation/issuing. Restart Keychain if needed.&lt;br /&gt;
&lt;br /&gt;
=== Can&#039;t find my token after creation ===&lt;br /&gt;
Refresh the page or check https://hive-engine.com/tokens — it may take a few seconds to appear.&lt;br /&gt;
&lt;br /&gt;
=== Want to issue more later? ===&lt;br /&gt;
You can issue up to your max supply at any time from the manage page.&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Action !! Where to Do It&lt;br /&gt;
|-&lt;br /&gt;
| Create token || https://tribaldex.com/tokens/create&lt;br /&gt;
|-&lt;br /&gt;
| Manage / Issue tokens || https://tribaldex.com/tokens/manage&lt;br /&gt;
|-&lt;br /&gt;
| View all tokens || https://hive-engine.com/tokens&lt;br /&gt;
|-&lt;br /&gt;
| Swap BEE || TribalDex market&lt;br /&gt;
|-&lt;br /&gt;
| Check balance in Keychain || Hive Keychain extension → Tokens tab&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Next step for v4call users&#039;&#039;&#039;: After creating your token, update your v4call-rates post using the improved rate-editor.html to include your custom token rates and blocklist bypass.&lt;br /&gt;
&lt;br /&gt;
You now have your own personal communication currency on Hive!&lt;br /&gt;
&lt;br /&gt;
== Staking &amp;lt;b&amp;gt;UNTESTED&amp;lt;/b&amp;gt; at current time ==&lt;br /&gt;
== Appendix A: Enabling Staking on an Existing Token (e.g. CNOOBS) ==&lt;br /&gt;
So you&#039;ve created your token but forgot to enable staking? No problem — staking can be enabled on an existing Hive Engine token at any time. This appendix walks you through it.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why enable staking?&#039;&#039;&#039;&lt;br /&gt;
* Lets holders &amp;quot;lock up&amp;quot; tokens for influence, rewards, or governance.&lt;br /&gt;
* Required if you ever want to add a reward pool (Scotbot/SMT) later — staking is a prerequisite.&lt;br /&gt;
* Useful in v4call: you can require holders to &#039;&#039;stake&#039;&#039; (not just hold) CNOOBS to bypass blocklists or unlock premium rates. Staked tokens can&#039;t be quickly dumped, so they prove genuine commitment.&lt;br /&gt;
* Adds scarcity — staked tokens are removed from the liquid circulating supply.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Cost&#039;&#039;&#039;: Approximately &#039;&#039;&#039;1000 BEE&#039;&#039;&#039; (one-time burn fee). This is significantly more than the 100 BEE token creation fee, so make sure you actually want this feature before paying. The fee is non-refundable.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important&#039;&#039;&#039;: Enabling staking is generally a one-way action. Once enabled it cannot be disabled. Some parameters (like cooldown period) can be edited later, but the staking feature itself is permanent. Plan your tokenomics before pulling the trigger.&lt;br /&gt;
&lt;br /&gt;
=== What You Need ===&lt;br /&gt;
* Your existing token (e.g. CNOOBS) created on Hive Engine.&lt;br /&gt;
* Your Hive account with Hive Keychain and the &#039;&#039;&#039;active key&#039;&#039;&#039; available.&lt;br /&gt;
* At least &#039;&#039;&#039;1010 BEE&#039;&#039;&#039; in your wallet (1000 for the fee + small buffer).&lt;br /&gt;
* A few minutes.&lt;br /&gt;
&lt;br /&gt;
=== Step A1: Get 1000+ BEE ===&lt;br /&gt;
Same as before — swap HIVE/HBD for BEE on [https://tribaldex.com tribaldex.com] or buy BEE on the Hive Engine market. Make sure you have at least 1010 BEE liquid in your wallet (not staked) before continuing.&lt;br /&gt;
&lt;br /&gt;
=== Step A2: Go to the Manage Tokens Page ===&lt;br /&gt;
# Visit [https://tribaldex.com/tokens/manage https://tribaldex.com/tokens/manage]&lt;br /&gt;
# Log in with Hive Keychain (active key) if not already logged in.&lt;br /&gt;
# Find your token (CNOOBS) in the list of tokens you own.&lt;br /&gt;
&lt;br /&gt;
=== Step A3: Click &amp;quot;Enable Staking&amp;quot; ===&lt;br /&gt;
# Next to your token, look for the &#039;&#039;&#039;Enable Staking&#039;&#039;&#039; button or icon (sometimes shown as a lock/coin symbol). Depending on UI updates, it may be under an &amp;quot;Actions&amp;quot; or &amp;quot;More&amp;quot; menu.&lt;br /&gt;
# A form will appear with the following fields:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Unstaking Cooldown (days)&#039;&#039;&#039; — How long it takes to unstake tokens once a holder requests it. Common values: 7, 14, 28, or 30 days. Longer = more commitment = more scarcity. For a personal communication token like CNOOBS, &#039;&#039;&#039;7 days&#039;&#039;&#039; is a reasonable starting point.&lt;br /&gt;
* &#039;&#039;&#039;Number of Transactions&#039;&#039;&#039; — How many payouts the unstake is split into. Example: 28 days over 4 transactions = the holder gets 25% of their unstaked tokens every 7 days. Common values: 1, 4, or matching the cooldown days.&lt;br /&gt;
* &#039;&#039;&#039;Enable Delegation&#039;&#039;&#039; (optional checkbox) — Lets users delegate (lend) staked tokens to others without unstaking. Highly recommended for v4call use cases (e.g. delegate CNOOBS to a friend so they can call you).&lt;br /&gt;
* &#039;&#039;&#039;Delegation Cooldown (days)&#039;&#039;&#039; (if delegation enabled) — How long after un-delegating before tokens return to the original staker. Usually 7 days.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Recommended starter settings for CNOOBS:&#039;&#039;&#039;&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Field !! Suggested Value&lt;br /&gt;
|-&lt;br /&gt;
| Unstaking Cooldown || 7 days&lt;br /&gt;
|-&lt;br /&gt;
| Number of Transactions || 4 (= 25% every ~1.75 days)&lt;br /&gt;
|-&lt;br /&gt;
| Enable Delegation || Yes (checked)&lt;br /&gt;
|-&lt;br /&gt;
| Delegation Cooldown || 7 days&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
# Double-check your settings — cooldown values can usually be edited later, but it costs additional small fees and confusion for holders.&lt;br /&gt;
# Click &#039;&#039;&#039;Enable Staking&#039;&#039;&#039; (or similar confirmation button).&lt;br /&gt;
# Hive Keychain will pop up asking you to approve a custom_json transaction that &#039;&#039;&#039;burns 1000 BEE&#039;&#039;&#039;. Confirm with your active key.&lt;br /&gt;
# Wait 3–10 seconds for blockchain confirmation.&lt;br /&gt;
&lt;br /&gt;
Success! Staking is now live on your token.&lt;br /&gt;
&lt;br /&gt;
=== Step A4: Verify Staking is Enabled ===&lt;br /&gt;
# Go to https://hive-engine.com/tokens&lt;br /&gt;
# Search for CNOOBS.&lt;br /&gt;
# Click the token — you should now see a &amp;quot;Stake&amp;quot; / &amp;quot;Unstake&amp;quot; / &amp;quot;Delegate&amp;quot; option visible to all holders.&lt;br /&gt;
# In your own Hive Keychain wallet, on the Tokens tab, CNOOBS should now show a small lock/stake icon next to the transfer button.&lt;br /&gt;
&lt;br /&gt;
Try staking 10 CNOOBS yourself as a test — it should appear under &amp;quot;Staked Balance&amp;quot; immediately.&lt;br /&gt;
&lt;br /&gt;
=== Step A5: Update v4call to Recognise Staked Tokens ===&lt;br /&gt;
Your v4call rate editor can distinguish between &#039;&#039;&#039;liquid&#039;&#039;&#039; and &#039;&#039;&#039;staked&#039;&#039;&#039; balances. Examples in V2 format:&lt;br /&gt;
&lt;br /&gt;
[LIST:liquid-holders]&lt;br /&gt;
TOKEN:CNOOBS&lt;br /&gt;
TYPE:LIQUID&lt;br /&gt;
TEXT:1 CNOOBS&lt;br /&gt;
&lt;br /&gt;
[LIST:committed-holders]&lt;br /&gt;
TOKEN:CNOOBS&lt;br /&gt;
TYPE:STAKED&lt;br /&gt;
MIN:100&lt;br /&gt;
TEXT:FREE&lt;br /&gt;
VOICE:FREE&lt;br /&gt;
ALLOW-IF-STAKED:CNOOBS:100&lt;br /&gt;
&lt;br /&gt;
This means: anyone holding 100+ &#039;&#039;&#039;staked&#039;&#039;&#039; CNOOBS gets free messaging and bypasses the blocklist, while liquid holders pay normal rates. Staking proves long-term commitment, not just speculation.&lt;br /&gt;
&lt;br /&gt;
=== Common Problems and Fixes (Staking) ===&lt;br /&gt;
&lt;br /&gt;
==== &amp;quot;Insufficient BEE balance&amp;quot; ====&lt;br /&gt;
You need 1000+ BEE liquid (not staked). Buy/swap more on TribalDex.&lt;br /&gt;
&lt;br /&gt;
==== &amp;quot;Enable Staking&amp;quot; button doesn&#039;t appear ====&lt;br /&gt;
* Make sure you&#039;re logged in as the token issuer (the account that created CNOOBS).&lt;br /&gt;
* Try a different Hive Engine RPC node — go to TribalDex settings and switch to a different node. Some nodes lag.&lt;br /&gt;
* Refresh and try again.&lt;br /&gt;
&lt;br /&gt;
==== Transaction succeeds but staking doesn&#039;t activate ====&lt;br /&gt;
Wait 30–60 seconds and refresh — Hive Engine sometimes takes a moment to propagate. Check https://hive-engine.com/tokens to confirm.&lt;br /&gt;
&lt;br /&gt;
==== Can I disable staking later? ====&lt;br /&gt;
Generally no — staking is a permanent feature once enabled. You &#039;&#039;can&#039;&#039; edit the cooldown period and delegation settings later (small Keychain transaction, no big fee), but the staking system itself stays on.&lt;br /&gt;
&lt;br /&gt;
==== Can I add a reward pool now? ====&lt;br /&gt;
Yes — with staking enabled, you can now optionally pay another 1000 BEE for Scotbot/SMT to enable post curation rewards in CNOOBS. Not required for v4call use, but available if you want to grow a community around the token.&lt;br /&gt;
&lt;br /&gt;
=== Updated Quick Reference ===&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Action !! Cost (BEE) !! Where&lt;br /&gt;
|-&lt;br /&gt;
| Create token || 100 || tribaldex.com/tokens/create&lt;br /&gt;
|-&lt;br /&gt;
| Issue tokens || Free (gas only) || tribaldex.com/tokens/manage&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Enable staking&#039;&#039;&#039; || &#039;&#039;&#039;1000&#039;&#039;&#039; || &#039;&#039;&#039;tribaldex.com/tokens/manage&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| Edit staking cooldown || ~Free (small fee) || tribaldex.com/tokens/manage&lt;br /&gt;
|-&lt;br /&gt;
| Enable Scotbot rewards (optional) || 1000 || tribaldex.com/smt/manage&lt;br /&gt;
|-&lt;br /&gt;
| View token info || Free || hive-engine.com/tokens&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Total cost so far for a staking-enabled CNOOBS&#039;&#039;&#039;: ~1100 BEE (100 create + 1000 staking).&lt;br /&gt;
&lt;br /&gt;
You now have a fully stakeable communication token — perfect for distinguishing committed v4call users from casual holders.&lt;br /&gt;
&lt;br /&gt;
[[Category:Hive]]&lt;br /&gt;
[[Category:Hive Engine]]&lt;br /&gt;
[[Category:v4call]]&lt;br /&gt;
[[Category:Token Creation]]&lt;br /&gt;
[[Category:Web3]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=752</id>
		<title>Hive Blockchain Create Custom Coins</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=752"/>
		<updated>2026-05-08T09:30:43Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Selling tokens on an exchange */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_CC0}}&lt;br /&gt;
= v4call — How to Create Your Own Custom Token (e.g. CNOOBS) on Hive Engine =&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
F&lt;br /&gt;
This guide walks you through creating a custom Hive Engine token (example: &#039;&#039;&#039;CNOOBS&#039;&#039;&#039;) in under 10 minutes. No coding required.&lt;br /&gt;
&lt;br /&gt;
These tokens power the custom communication economy in v4call: you can set rates like &amp;quot;1 CNOOBS = 1 text message&amp;quot; or &amp;quot;10 CNOOBS = 1 hour video call&amp;quot;, gift them to friends/family, or let blocked users bypass your blocklist if they hold enough of your token.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why create your own token?&#039;&#039;&#039;&lt;br /&gt;
* Full control over supply and distribution.&lt;br /&gt;
* Real utility inside v4call (and any other Hive dApp that supports Hive Engine tokens).&lt;br /&gt;
* Scarcity you decide — perfect for personal or community communication economies.&lt;br /&gt;
* Transferable, tradable, and giftable via Hive Keychain or TribalDex.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Cost&#039;&#039;&#039;: Approximately 100 BEE (Hive Engine&#039;s utility token). BEE price fluctuates — check current value on TribalDex or PeakD. This is a one-time creation fee.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Source&#039;&#039;&#039;: Based on the official Hive Engine / TribalDex interface (as of 2026).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;End result&#039;&#039;&#039;: A live custom token (e.g. CNOOBS) that appears in users&#039; Hive Keychain wallets and can be used in your v4call rates post.&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
* [[#What_You_Need|1 What You Need]]&lt;br /&gt;
* [[#Step_1:_Get_Some_BEE_Tokens|2 Step 1: Get Some BEE Tokens]]&lt;br /&gt;
* [[#Step_2:_Log_In_to_TribalDex|3 Step 2: Log In to TribalDex]]&lt;br /&gt;
* [[#Step_3:_Create_Your_Token|4 Step 3: Create Your Token]]&lt;br /&gt;
* [[#Step_4:_Issue_Tokens_to_Yourself|5 Step 4: Issue Tokens to Yourself]]&lt;br /&gt;
* [[#Step_5:_Optional_-_Add_Metadata_(Logo_Description)|6 Step 5: Optional - Add Metadata (Logo &amp;amp; Description)]]&lt;br /&gt;
* [[#Step_6:_Distribute_Your_Tokens|7 Step 6: Distribute Your Tokens]]&lt;br /&gt;
* [[#Using_Your_Token_in_v4call|8 Using Your Token in v4call]]&lt;br /&gt;
* [[#Common_Problems_and_Fixes|9 Common Problems and Fixes]]&lt;br /&gt;
* [[#Quick_Reference|10 Quick Reference]]&lt;br /&gt;
&lt;br /&gt;
== What You Need ==&lt;br /&gt;
* A Hive account (e.g. @noblemage) with Hive Keychain installed and the &#039;&#039;&#039;active key&#039;&#039;&#039; available.&lt;br /&gt;
* Some BEE tokens (≈100 BEE for creation fee).&lt;br /&gt;
* A web browser.&lt;br /&gt;
&lt;br /&gt;
You do &#039;&#039;&#039;not&#039;&#039;&#039; need to run a node or write code.&lt;br /&gt;
&lt;br /&gt;
== Step 1: Get Some BEE Tokens ==&lt;br /&gt;
BEE is the &amp;quot;gas&amp;quot; token for Hive Engine actions.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com tribaldex.com] or any Hive Engine market (e.g. via PeakD wallet).&lt;br /&gt;
# Swap or buy BEE using HIVE or HBD (very easy with Keychain).&lt;br /&gt;
# Alternative: Many users get small amounts of BEE from community airdrops or by swapping on the built-in market.&lt;br /&gt;
&lt;br /&gt;
Make sure you have at least 110 BEE to cover the fee plus a small buffer.&lt;br /&gt;
&lt;br /&gt;
== Step 2: Log In to TribalDex ==&lt;br /&gt;
# Visit [https://tribaldex.com/tokens/create https://tribaldex.com/tokens/create]&lt;br /&gt;
# Click the login button at the top.&lt;br /&gt;
# Hive Keychain will pop up — approve the login with your &#039;&#039;&#039;active key&#039;&#039;&#039; (or posting key in some cases, but active is safest for token actions).&lt;br /&gt;
&lt;br /&gt;
You are now logged in as your Hive account.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Create Your Token ==&lt;br /&gt;
On the token creation form, fill in the following fields carefully. Most cannot be changed later.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Symbol&#039;&#039;&#039; — e.g. &#039;&#039;&#039;CNOOBS&#039;&#039;&#039; (uppercase, 1–10 characters, unique)&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; — e.g. &#039;&#039;&#039;Cnoobs Coins&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Max Supply&#039;&#039;&#039; — Choose a number (e.g. 1,000,000). This is the absolute maximum that can ever exist. You can issue less.&lt;br /&gt;
* &#039;&#039;&#039;Precision&#039;&#039;&#039; — Usually &#039;&#039;&#039;3&#039;&#039;&#039; (allows 0.001 precision). You can only increase this later, not decrease.&lt;br /&gt;
* &#039;&#039;&#039;Website&#039;&#039;&#039; (optional) — Link to your profile or v4call server (e.g. https://call.yourdomain.com)&lt;br /&gt;
* &#039;&#039;&#039;Description&#039;&#039;&#039; (optional) — Short text like &amp;quot;Personal communication token for v4call — used for messages and calls with @cnoobz&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Double-check everything — the symbol especially cannot be changed.&lt;br /&gt;
&lt;br /&gt;
Click &#039;&#039;&#039;Create Token&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Hive Keychain will ask you to approve a custom_json transaction. Confirm it.&lt;br /&gt;
&lt;br /&gt;
Wait 3–10 seconds for confirmation on the blockchain.&lt;br /&gt;
&lt;br /&gt;
Success message should appear: your token is now created!&lt;br /&gt;
&lt;br /&gt;
== Step 4: Issue Tokens to Yourself ==&lt;br /&gt;
After creation, you usually start with zero balance.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com/tokens/manage https://tribaldex.com/tokens/manage]&lt;br /&gt;
# Find your new token (CNOOBS) in the list.&lt;br /&gt;
# Click the &#039;&#039;&#039;Issue&#039;&#039;&#039; button.&lt;br /&gt;
# Enter the amount you want to issue to yourself (e.g. 10000).&lt;br /&gt;
# Confirm with Keychain.&lt;br /&gt;
&lt;br /&gt;
You now hold the full issued supply.&lt;br /&gt;
&lt;br /&gt;
== Step 5: Optional — Add Metadata (Logo &amp;amp; Description) ==&lt;br /&gt;
# Still on the manage page, click the edit icon for your token.&lt;br /&gt;
# Upload a square logo image (recommended 200x200 px or larger).&lt;br /&gt;
# Improve the description and website link.&lt;br /&gt;
# Save changes (another small Keychain transaction).&lt;br /&gt;
&lt;br /&gt;
This makes your token look professional when users view it in wallets or markets.&lt;br /&gt;
&lt;br /&gt;
== Step 6: Distribute Your Tokens ==&lt;br /&gt;
* Send to friends/family via Keychain → &amp;quot;Tokens&amp;quot; tab → Transfer.&lt;br /&gt;
* Airdrop to your community.&lt;br /&gt;
* Use in v4call rates (see below).&lt;br /&gt;
* List on TribalDex market if you want people to buy/sell them.&lt;br /&gt;
&lt;br /&gt;
Tokens are fully transferable and appear instantly in recipients&#039; Hive Keychain wallets.&lt;br /&gt;
&lt;br /&gt;
== Using Your Token in v4call ==&lt;br /&gt;
Once you have your token:&lt;br /&gt;
&lt;br /&gt;
# Go to your v4call server → /rate-editor.html&lt;br /&gt;
# In the rate editor (V2 format), create a new list like [LIST:token-holders]&lt;br /&gt;
# Set TOKEN:CNOOBS&lt;br /&gt;
# Define rates, e.g.:&lt;br /&gt;
** TEXT:1 CNOOBS&lt;br /&gt;
** VOICE:RING:5 CNOOBS;CONNECT:10 CNOOBS;RATE:20 CNOOBS/hr&lt;br /&gt;
* Your server will automatically verify balances using public Hive Engine RPC when someone tries to call or message you.&lt;br /&gt;
&lt;br /&gt;
Blocked users can bypass your blocklist if you set ALLOW-IF-TOKEN:CNOOBS and they hold enough.&lt;br /&gt;
&lt;br /&gt;
== Exchanging tokens on an exchange ==&lt;br /&gt;
Its pretty simple - you can exchange with hive-engine.com&lt;br /&gt;
* https://hive-engine.com/trade/CNOOBS&lt;br /&gt;
&amp;lt;code&amp;gt;https://hive-engine.com/trade/&amp;lt;YOUR_TOKEN&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Common Problems and Fixes ==&lt;br /&gt;
=== &amp;quot;Not enough BEE&amp;quot; error ===&lt;br /&gt;
Buy or swap more BEE on TribalDex.&lt;br /&gt;
&lt;br /&gt;
=== Token symbol already taken ===&lt;br /&gt;
Choose a different symbol (add numbers or make it longer, e.g. CNOOBS2).&lt;br /&gt;
&lt;br /&gt;
=== Transaction fails ===&lt;br /&gt;
Make sure you are using the &#039;&#039;&#039;active key&#039;&#039;&#039; in Keychain for token creation/issuing. Restart Keychain if needed.&lt;br /&gt;
&lt;br /&gt;
=== Can&#039;t find my token after creation ===&lt;br /&gt;
Refresh the page or check https://hive-engine.com/tokens — it may take a few seconds to appear.&lt;br /&gt;
&lt;br /&gt;
=== Want to issue more later? ===&lt;br /&gt;
You can issue up to your max supply at any time from the manage page.&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Action !! Where to Do It&lt;br /&gt;
|-&lt;br /&gt;
| Create token || https://tribaldex.com/tokens/create&lt;br /&gt;
|-&lt;br /&gt;
| Manage / Issue tokens || https://tribaldex.com/tokens/manage&lt;br /&gt;
|-&lt;br /&gt;
| View all tokens || https://hive-engine.com/tokens&lt;br /&gt;
|-&lt;br /&gt;
| Swap BEE || TribalDex market&lt;br /&gt;
|-&lt;br /&gt;
| Check balance in Keychain || Hive Keychain extension → Tokens tab&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Next step for v4call users&#039;&#039;&#039;: After creating your token, update your v4call-rates post using the improved rate-editor.html to include your custom token rates and blocklist bypass.&lt;br /&gt;
&lt;br /&gt;
You now have your own personal communication currency on Hive!&lt;br /&gt;
&lt;br /&gt;
[[Category:Hive]]&lt;br /&gt;
[[Category:Hive Engine]]&lt;br /&gt;
[[Category:v4call]]&lt;br /&gt;
[[Category:Token Creation]]&lt;br /&gt;
[[Category:Web3]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=751</id>
		<title>Hive Blockchain Create Custom Coins</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=751"/>
		<updated>2026-05-08T09:30:25Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Using Your Token in v4call */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_CC0}}&lt;br /&gt;
= v4call — How to Create Your Own Custom Token (e.g. CNOOBS) on Hive Engine =&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
F&lt;br /&gt;
This guide walks you through creating a custom Hive Engine token (example: &#039;&#039;&#039;CNOOBS&#039;&#039;&#039;) in under 10 minutes. No coding required.&lt;br /&gt;
&lt;br /&gt;
These tokens power the custom communication economy in v4call: you can set rates like &amp;quot;1 CNOOBS = 1 text message&amp;quot; or &amp;quot;10 CNOOBS = 1 hour video call&amp;quot;, gift them to friends/family, or let blocked users bypass your blocklist if they hold enough of your token.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why create your own token?&#039;&#039;&#039;&lt;br /&gt;
* Full control over supply and distribution.&lt;br /&gt;
* Real utility inside v4call (and any other Hive dApp that supports Hive Engine tokens).&lt;br /&gt;
* Scarcity you decide — perfect for personal or community communication economies.&lt;br /&gt;
* Transferable, tradable, and giftable via Hive Keychain or TribalDex.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Cost&#039;&#039;&#039;: Approximately 100 BEE (Hive Engine&#039;s utility token). BEE price fluctuates — check current value on TribalDex or PeakD. This is a one-time creation fee.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Source&#039;&#039;&#039;: Based on the official Hive Engine / TribalDex interface (as of 2026).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;End result&#039;&#039;&#039;: A live custom token (e.g. CNOOBS) that appears in users&#039; Hive Keychain wallets and can be used in your v4call rates post.&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
* [[#What_You_Need|1 What You Need]]&lt;br /&gt;
* [[#Step_1:_Get_Some_BEE_Tokens|2 Step 1: Get Some BEE Tokens]]&lt;br /&gt;
* [[#Step_2:_Log_In_to_TribalDex|3 Step 2: Log In to TribalDex]]&lt;br /&gt;
* [[#Step_3:_Create_Your_Token|4 Step 3: Create Your Token]]&lt;br /&gt;
* [[#Step_4:_Issue_Tokens_to_Yourself|5 Step 4: Issue Tokens to Yourself]]&lt;br /&gt;
* [[#Step_5:_Optional_-_Add_Metadata_(Logo_Description)|6 Step 5: Optional - Add Metadata (Logo &amp;amp; Description)]]&lt;br /&gt;
* [[#Step_6:_Distribute_Your_Tokens|7 Step 6: Distribute Your Tokens]]&lt;br /&gt;
* [[#Using_Your_Token_in_v4call|8 Using Your Token in v4call]]&lt;br /&gt;
* [[#Common_Problems_and_Fixes|9 Common Problems and Fixes]]&lt;br /&gt;
* [[#Quick_Reference|10 Quick Reference]]&lt;br /&gt;
&lt;br /&gt;
== What You Need ==&lt;br /&gt;
* A Hive account (e.g. @noblemage) with Hive Keychain installed and the &#039;&#039;&#039;active key&#039;&#039;&#039; available.&lt;br /&gt;
* Some BEE tokens (≈100 BEE for creation fee).&lt;br /&gt;
* A web browser.&lt;br /&gt;
&lt;br /&gt;
You do &#039;&#039;&#039;not&#039;&#039;&#039; need to run a node or write code.&lt;br /&gt;
&lt;br /&gt;
== Step 1: Get Some BEE Tokens ==&lt;br /&gt;
BEE is the &amp;quot;gas&amp;quot; token for Hive Engine actions.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com tribaldex.com] or any Hive Engine market (e.g. via PeakD wallet).&lt;br /&gt;
# Swap or buy BEE using HIVE or HBD (very easy with Keychain).&lt;br /&gt;
# Alternative: Many users get small amounts of BEE from community airdrops or by swapping on the built-in market.&lt;br /&gt;
&lt;br /&gt;
Make sure you have at least 110 BEE to cover the fee plus a small buffer.&lt;br /&gt;
&lt;br /&gt;
== Step 2: Log In to TribalDex ==&lt;br /&gt;
# Visit [https://tribaldex.com/tokens/create https://tribaldex.com/tokens/create]&lt;br /&gt;
# Click the login button at the top.&lt;br /&gt;
# Hive Keychain will pop up — approve the login with your &#039;&#039;&#039;active key&#039;&#039;&#039; (or posting key in some cases, but active is safest for token actions).&lt;br /&gt;
&lt;br /&gt;
You are now logged in as your Hive account.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Create Your Token ==&lt;br /&gt;
On the token creation form, fill in the following fields carefully. Most cannot be changed later.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Symbol&#039;&#039;&#039; — e.g. &#039;&#039;&#039;CNOOBS&#039;&#039;&#039; (uppercase, 1–10 characters, unique)&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; — e.g. &#039;&#039;&#039;Cnoobs Coins&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Max Supply&#039;&#039;&#039; — Choose a number (e.g. 1,000,000). This is the absolute maximum that can ever exist. You can issue less.&lt;br /&gt;
* &#039;&#039;&#039;Precision&#039;&#039;&#039; — Usually &#039;&#039;&#039;3&#039;&#039;&#039; (allows 0.001 precision). You can only increase this later, not decrease.&lt;br /&gt;
* &#039;&#039;&#039;Website&#039;&#039;&#039; (optional) — Link to your profile or v4call server (e.g. https://call.yourdomain.com)&lt;br /&gt;
* &#039;&#039;&#039;Description&#039;&#039;&#039; (optional) — Short text like &amp;quot;Personal communication token for v4call — used for messages and calls with @cnoobz&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Double-check everything — the symbol especially cannot be changed.&lt;br /&gt;
&lt;br /&gt;
Click &#039;&#039;&#039;Create Token&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Hive Keychain will ask you to approve a custom_json transaction. Confirm it.&lt;br /&gt;
&lt;br /&gt;
Wait 3–10 seconds for confirmation on the blockchain.&lt;br /&gt;
&lt;br /&gt;
Success message should appear: your token is now created!&lt;br /&gt;
&lt;br /&gt;
== Step 4: Issue Tokens to Yourself ==&lt;br /&gt;
After creation, you usually start with zero balance.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com/tokens/manage https://tribaldex.com/tokens/manage]&lt;br /&gt;
# Find your new token (CNOOBS) in the list.&lt;br /&gt;
# Click the &#039;&#039;&#039;Issue&#039;&#039;&#039; button.&lt;br /&gt;
# Enter the amount you want to issue to yourself (e.g. 10000).&lt;br /&gt;
# Confirm with Keychain.&lt;br /&gt;
&lt;br /&gt;
You now hold the full issued supply.&lt;br /&gt;
&lt;br /&gt;
== Step 5: Optional — Add Metadata (Logo &amp;amp; Description) ==&lt;br /&gt;
# Still on the manage page, click the edit icon for your token.&lt;br /&gt;
# Upload a square logo image (recommended 200x200 px or larger).&lt;br /&gt;
# Improve the description and website link.&lt;br /&gt;
# Save changes (another small Keychain transaction).&lt;br /&gt;
&lt;br /&gt;
This makes your token look professional when users view it in wallets or markets.&lt;br /&gt;
&lt;br /&gt;
== Step 6: Distribute Your Tokens ==&lt;br /&gt;
* Send to friends/family via Keychain → &amp;quot;Tokens&amp;quot; tab → Transfer.&lt;br /&gt;
* Airdrop to your community.&lt;br /&gt;
* Use in v4call rates (see below).&lt;br /&gt;
* List on TribalDex market if you want people to buy/sell them.&lt;br /&gt;
&lt;br /&gt;
Tokens are fully transferable and appear instantly in recipients&#039; Hive Keychain wallets.&lt;br /&gt;
&lt;br /&gt;
== Using Your Token in v4call ==&lt;br /&gt;
Once you have your token:&lt;br /&gt;
&lt;br /&gt;
# Go to your v4call server → /rate-editor.html&lt;br /&gt;
# In the rate editor (V2 format), create a new list like [LIST:token-holders]&lt;br /&gt;
# Set TOKEN:CNOOBS&lt;br /&gt;
# Define rates, e.g.:&lt;br /&gt;
** TEXT:1 CNOOBS&lt;br /&gt;
** VOICE:RING:5 CNOOBS;CONNECT:10 CNOOBS;RATE:20 CNOOBS/hr&lt;br /&gt;
* Your server will automatically verify balances using public Hive Engine RPC when someone tries to call or message you.&lt;br /&gt;
&lt;br /&gt;
Blocked users can bypass your blocklist if you set ALLOW-IF-TOKEN:CNOOBS and they hold enough.&lt;br /&gt;
&lt;br /&gt;
== Selling tokens on an exchange ==&lt;br /&gt;
Its pretty simple - you can exchange with hive-engine.com&lt;br /&gt;
* https://hive-engine.com/trade/CNOOBS&lt;br /&gt;
&amp;lt;code&amp;gt;https://hive-engine.com/trade/&amp;lt;YOUR_TOKEN&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Common Problems and Fixes ==&lt;br /&gt;
=== &amp;quot;Not enough BEE&amp;quot; error ===&lt;br /&gt;
Buy or swap more BEE on TribalDex.&lt;br /&gt;
&lt;br /&gt;
=== Token symbol already taken ===&lt;br /&gt;
Choose a different symbol (add numbers or make it longer, e.g. CNOOBS2).&lt;br /&gt;
&lt;br /&gt;
=== Transaction fails ===&lt;br /&gt;
Make sure you are using the &#039;&#039;&#039;active key&#039;&#039;&#039; in Keychain for token creation/issuing. Restart Keychain if needed.&lt;br /&gt;
&lt;br /&gt;
=== Can&#039;t find my token after creation ===&lt;br /&gt;
Refresh the page or check https://hive-engine.com/tokens — it may take a few seconds to appear.&lt;br /&gt;
&lt;br /&gt;
=== Want to issue more later? ===&lt;br /&gt;
You can issue up to your max supply at any time from the manage page.&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Action !! Where to Do It&lt;br /&gt;
|-&lt;br /&gt;
| Create token || https://tribaldex.com/tokens/create&lt;br /&gt;
|-&lt;br /&gt;
| Manage / Issue tokens || https://tribaldex.com/tokens/manage&lt;br /&gt;
|-&lt;br /&gt;
| View all tokens || https://hive-engine.com/tokens&lt;br /&gt;
|-&lt;br /&gt;
| Swap BEE || TribalDex market&lt;br /&gt;
|-&lt;br /&gt;
| Check balance in Keychain || Hive Keychain extension → Tokens tab&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Next step for v4call users&#039;&#039;&#039;: After creating your token, update your v4call-rates post using the improved rate-editor.html to include your custom token rates and blocklist bypass.&lt;br /&gt;
&lt;br /&gt;
You now have your own personal communication currency on Hive!&lt;br /&gt;
&lt;br /&gt;
[[Category:Hive]]&lt;br /&gt;
[[Category:Hive Engine]]&lt;br /&gt;
[[Category:v4call]]&lt;br /&gt;
[[Category:Token Creation]]&lt;br /&gt;
[[Category:Web3]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_-How_to_buy_SWAP.BTC&amp;diff=750</id>
		<title>Hive Blockchain -How to buy SWAP.BTC</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_-How_to_buy_SWAP.BTC&amp;diff=750"/>
		<updated>2026-05-04T18:02:17Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;= Swapping HIVE / HBD to SWAP.BTC (and back again) =  &amp;#039;&amp;#039;A complete noobs walk-through to converting your Hive tokens into pegged Bitcoin (SWAP.BTC) on Hive-Engine, and then converting them back to HIVE or HBD.&amp;#039;&amp;#039;  This guide assumes you already have:  * A working Hive account * Hive Keychain browser extension installed and unlocked * Either &amp;#039;&amp;#039;&amp;#039;~$10 of HBD&amp;#039;&amp;#039;&amp;#039; OR &amp;#039;&amp;#039;&amp;#039;~1000 HIVE&amp;#039;&amp;#039;&amp;#039; in your wallet * A basic understanding of what HIVE and HBD are  If any of the above is not tru...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Swapping HIVE / HBD to SWAP.BTC (and back again) =&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;A complete noobs walk-through to converting your Hive tokens into pegged Bitcoin (SWAP.BTC) on Hive-Engine, and then converting them back to HIVE or HBD.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
This guide assumes you already have:&lt;br /&gt;
&lt;br /&gt;
* A working Hive account&lt;br /&gt;
* Hive Keychain browser extension installed and unlocked&lt;br /&gt;
* Either &#039;&#039;&#039;~$10 of HBD&#039;&#039;&#039; OR &#039;&#039;&#039;~1000 HIVE&#039;&#039;&#039; in your wallet&lt;br /&gt;
* A basic understanding of what HIVE and HBD are&lt;br /&gt;
&lt;br /&gt;
If any of the above is not true, stop here and read [[Hive Blockchain Basics]] and [[Setting up Hive Keychain]] first.&lt;br /&gt;
&lt;br /&gt;
== TL;DR ==&lt;br /&gt;
&lt;br /&gt;
You cannot swap HBD directly to SWAP.BTC in one click. You have to do it in stages:&lt;br /&gt;
&lt;br /&gt;
# Convert &#039;&#039;&#039;HBD → HIVE&#039;&#039;&#039; (using @swap.app)&lt;br /&gt;
# Move &#039;&#039;&#039;HIVE → SWAP.HIVE&#039;&#039;&#039; (deposit onto the Hive-Engine sidechain)&lt;br /&gt;
# Trade &#039;&#039;&#039;SWAP.HIVE → SWAP.BTC&#039;&#039;&#039; (on the Hive-Engine market)&lt;br /&gt;
&lt;br /&gt;
To go back, you reverse the whole thing.&lt;br /&gt;
&lt;br /&gt;
Each step costs a small fee. Read the whole guide before you start so you understand what is happening at each stage.&lt;br /&gt;
&lt;br /&gt;
== What is SWAP.BTC anyway? ==&lt;br /&gt;
&lt;br /&gt;
SWAP.BTC is &#039;&#039;&#039;not real Bitcoin&#039;&#039;&#039;. It is a &amp;quot;pegged&amp;quot; token that lives on Hive-Engine (a smart-contract sidechain of Hive). For every 1 SWAP.BTC that exists, there is supposed to be 1 real BTC held by the gateway operator (@honey-swap) on the actual Bitcoin blockchain. You can withdraw SWAP.BTC out to a real Bitcoin wallet at any time (subject to a minimum withdrawal amount — see below), and it becomes real BTC again.&lt;br /&gt;
&lt;br /&gt;
The same idea applies to &#039;&#039;&#039;SWAP.ETH&#039;&#039;&#039;, &#039;&#039;&#039;SWAP.DOGE&#039;&#039;&#039;, &#039;&#039;&#039;SWAP.LTC&#039;&#039;&#039;, &#039;&#039;&#039;SWAP.BNB&#039;&#039;&#039;, &#039;&#039;&#039;SWAP.USDT&#039;&#039;&#039; and others.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;span style=&amp;quot;color:red&amp;quot;&amp;gt;Important:&amp;lt;/span&amp;gt;&#039;&#039;&#039; Pegged tokens rely on a single gateway operator. If that operator vanishes, your SWAP.BTC may not be redeemable for real BTC. Do &#039;&#039;&#039;not&#039;&#039;&#039; park large sums in pegged tokens long-term. Treat them as a tool for moving value, not a place to store it.&lt;br /&gt;
&lt;br /&gt;
== Step 0: Vocabulary you will need ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Term !! What it means&lt;br /&gt;
|-&lt;br /&gt;
| HIVE || The native liquid token of the Hive blockchain.&lt;br /&gt;
|-&lt;br /&gt;
| HBD || Hive Backed Dollar. A stablecoin loosely pegged to $1 USD.&lt;br /&gt;
|-&lt;br /&gt;
| Hive Power (HP) || Staked HIVE. Not relevant to this guide. Ignore it.&lt;br /&gt;
|-&lt;br /&gt;
| SWAP.HIVE || A 1:1 wrapped version of HIVE that lives on Hive-Engine. You &#039;&#039;&#039;must&#039;&#039;&#039; have SWAP.HIVE before you can buy SWAP.BTC.&lt;br /&gt;
|-&lt;br /&gt;
| Hive-Engine || A sidechain that hosts custom tokens, including all the SWAP.* pegged tokens. Accessed at hive-engine.com.&lt;br /&gt;
|-&lt;br /&gt;
| @honey-swap || The gateway account that holds the real BTC, ETH, DOGE etc. backing the SWAP tokens.&lt;br /&gt;
|-&lt;br /&gt;
| Active key || The Hive Keychain key needed to authorise transfers and trades. Keychain handles this for you, but it will pop up asking you to confirm.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Part 1: Going from HBD or HIVE to SWAP.BTC ==&lt;br /&gt;
&lt;br /&gt;
=== Step 1: (HBD holders only) Convert HBD to HIVE ===&lt;br /&gt;
&lt;br /&gt;
If you already have HIVE and not HBD, &#039;&#039;&#039;skip to Step 2&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Hive-Engine deposits use HIVE, not HBD. So your $10 of HBD needs to become HIVE first. The easiest way is to use @swap.app:&lt;br /&gt;
&lt;br /&gt;
# Go to your wallet at wallet.hive.blog (or the wallet tab in PeakD / Ecency).&lt;br /&gt;
# Click the dropdown next to your &#039;&#039;&#039;HBD&#039;&#039;&#039; balance and choose &#039;&#039;&#039;Transfer&#039;&#039;&#039;.&lt;br /&gt;
# In the recipient field, type: &amp;lt;code&amp;gt;swap.app&amp;lt;/code&amp;gt;&lt;br /&gt;
# In the amount field, type the amount of HBD you want to swap (e.g. &amp;lt;code&amp;gt;10.000 HBD&amp;lt;/code&amp;gt;).&lt;br /&gt;
# Leave the memo &#039;&#039;&#039;blank&#039;&#039;&#039;. (Sending HBD with no memo means &amp;quot;convert to HIVE&amp;quot;.)&lt;br /&gt;
# Click &#039;&#039;&#039;Send&#039;&#039;&#039; and approve in Keychain.&lt;br /&gt;
# Within about 30 seconds, an equivalent amount of HIVE (minus a 0.1% fee) will appear in your wallet.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Do not&#039;&#039;&#039; use the blockchain&#039;s built-in HBD → HIVE &amp;quot;convert&amp;quot; button. It takes 3.5 days and uses an averaged price that may give you less HIVE than you expect.&lt;br /&gt;
&lt;br /&gt;
=== Step 2: Move HIVE onto Hive-Engine (HIVE → SWAP.HIVE) ===&lt;br /&gt;
&lt;br /&gt;
Tested and working at the time of writing. This is the algorithm we are sticking with — keep it simple, one site, one route:&lt;br /&gt;
&lt;br /&gt;
# Go to &#039;&#039;&#039;hive-engine.com&#039;&#039;&#039;.&lt;br /&gt;
# Click &#039;&#039;&#039;Login&#039;&#039;&#039; (top right) and sign in with Hive Keychain. Approve in Keychain.&lt;br /&gt;
# Click &#039;&#039;&#039;Wallet&#039;&#039;&#039; (top menu).&lt;br /&gt;
# Find &#039;&#039;&#039;SWAP.HIVE&#039;&#039;&#039; in the token list. Click &#039;&#039;&#039;Deposit&#039;&#039;&#039;.&lt;br /&gt;
# Select &#039;&#039;&#039;HIVE&#039;&#039;&#039; as the currency to deposit.&lt;br /&gt;
# Enter the amount of HIVE you want to deposit (e.g. &amp;lt;code&amp;gt;10&amp;lt;/code&amp;gt;).&lt;br /&gt;
# &#039;&#039;&#039;There is a 0.75% fee on deposits.&#039;&#039;&#039; For 10 HIVE you will receive &#039;&#039;&#039;9.925 SWAP.HIVE&#039;&#039;&#039;.&lt;br /&gt;
# Click &#039;&#039;&#039;Deposit&#039;&#039;&#039;. Keychain will pop up asking you to confirm a transfer of HIVE to &amp;lt;code&amp;gt;@honey-swap&amp;lt;/code&amp;gt; with a memo. &#039;&#039;&#039;Do not edit the memo&#039;&#039;&#039; — it tells the gateway who to credit. Click &#039;&#039;&#039;Confirm&#039;&#039;&#039;.&lt;br /&gt;
# Wait 30 seconds and refresh. SWAP.HIVE will appear in your Hive-Engine wallet.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;span style=&amp;quot;color:red&amp;quot;&amp;gt;Common mistake:&amp;lt;/span&amp;gt;&#039;&#039;&#039; If you edit the memo, the gateway will not know which Hive-Engine account to credit, and your funds may be lost. Just leave the memo alone.&lt;br /&gt;
&lt;br /&gt;
=== Step 3: Trade SWAP.HIVE for SWAP.BTC ===&lt;br /&gt;
&lt;br /&gt;
Now you finally get to buy your pegged Bitcoin.&lt;br /&gt;
&lt;br /&gt;
# While still logged into hive-engine.com, click &#039;&#039;&#039;Market&#039;&#039;&#039; (top menu).&lt;br /&gt;
# Search for and select &#039;&#039;&#039;SWAP.BTC&#039;&#039;&#039;. The trading pair shown will be &#039;&#039;&#039;SWAP.BTC / SWAP.HIVE&#039;&#039;&#039;.&lt;br /&gt;
# You will see two columns of orders:&lt;br /&gt;
#* &#039;&#039;&#039;Sell orders&#039;&#039;&#039; — people offering to sell SWAP.BTC, sorted by lowest price first.&lt;br /&gt;
#* &#039;&#039;&#039;Buy orders&#039;&#039;&#039; — people bidding to buy SWAP.BTC.&lt;br /&gt;
# To buy SWAP.BTC instantly, click on the &#039;&#039;&#039;lowest sell order&#039;&#039;&#039; price. This auto-fills the price field in the &#039;&#039;&#039;Buy SWAP.BTC&#039;&#039;&#039; box.&lt;br /&gt;
# Enter how much SWAP.HIVE you want to spend in the &#039;&#039;&#039;Total&#039;&#039;&#039; field. The &#039;&#039;&#039;Quantity&#039;&#039;&#039; (amount of SWAP.BTC you will receive) auto-calculates.&lt;br /&gt;
# Double-check the numbers. Click &#039;&#039;&#039;Buy&#039;&#039;&#039;. Keychain pops up. Approve.&lt;br /&gt;
# Refresh your wallet. You should now see SWAP.BTC.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;span style=&amp;quot;color:red&amp;quot;&amp;gt;Sanity check:&amp;lt;/span&amp;gt;&#039;&#039;&#039; Compare the SWAP.BTC price on Hive-Engine with the real BTC/USD price on a major exchange. They should be close. If SWAP.BTC is trading at a noticeable premium or discount, that reflects market trust in the gateway — be cautious if the gap is large.&lt;br /&gt;
&lt;br /&gt;
== Part 2: Going back (SWAP.BTC → HIVE / HBD) ==&lt;br /&gt;
&lt;br /&gt;
You reverse the chain. Same site, same wallet.&lt;br /&gt;
&lt;br /&gt;
=== Step 4: Trade SWAP.BTC back to SWAP.HIVE ===&lt;br /&gt;
&lt;br /&gt;
# hive-engine.com → &#039;&#039;&#039;Market&#039;&#039;&#039; → SWAP.BTC.&lt;br /&gt;
# This time use the &#039;&#039;&#039;Sell SWAP.BTC&#039;&#039;&#039; box.&lt;br /&gt;
# Click on the highest &#039;&#039;&#039;buy order&#039;&#039;&#039; to auto-fill the price.&lt;br /&gt;
# Enter the SWAP.BTC quantity you want to sell. Click &#039;&#039;&#039;Sell&#039;&#039;&#039;. Approve in Keychain.&lt;br /&gt;
# Your SWAP.HIVE balance increases.&lt;br /&gt;
&lt;br /&gt;
=== Step 5: Move SWAP.HIVE back to HIVE ===&lt;br /&gt;
&lt;br /&gt;
# hive-engine.com → &#039;&#039;&#039;Wallet&#039;&#039;&#039;.&lt;br /&gt;
# Find SWAP.HIVE and click &#039;&#039;&#039;Withdraw&#039;&#039;&#039;.&lt;br /&gt;
# Select &#039;&#039;&#039;HIVE&#039;&#039;&#039; as the destination.&lt;br /&gt;
# Enter the amount. The destination is automatically your own Hive account.&lt;br /&gt;
# &#039;&#039;&#039;There is a 0.75% fee on withdrawals.&#039;&#039;&#039; For 10 SWAP.HIVE you will receive 9.925 HIVE.&lt;br /&gt;
# Approve in Keychain.&lt;br /&gt;
# Within ~30 seconds, the HIVE appears in your normal Hive wallet.&lt;br /&gt;
&lt;br /&gt;
=== Step 6: (Optional) Convert HIVE back to HBD ===&lt;br /&gt;
&lt;br /&gt;
If you want to end up holding HBD again rather than HIVE:&lt;br /&gt;
&lt;br /&gt;
# Wallet → &#039;&#039;&#039;Transfer&#039;&#039;&#039; HIVE → recipient: &amp;lt;code&amp;gt;swap.app&amp;lt;/code&amp;gt; → memo: &amp;lt;code&amp;gt;hbd&amp;lt;/code&amp;gt; → Send.&lt;br /&gt;
# A few seconds later, equivalent HBD lands in your wallet.&lt;br /&gt;
&lt;br /&gt;
The memo &amp;lt;code&amp;gt;hbd&amp;lt;/code&amp;gt; is what tells @swap.app to convert in this direction. If you forget the memo, it will assume you want HIVE and bounce your tokens back unchanged (or convert HBD to HIVE if that&#039;s what you sent).&lt;br /&gt;
&lt;br /&gt;
== Important: getting actual BTC out ==&lt;br /&gt;
&lt;br /&gt;
If your goal is to eventually get &#039;&#039;&#039;real Bitcoin&#039;&#039;&#039; on the Bitcoin blockchain (rather than just holding SWAP.BTC inside Hive-Engine), be aware:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Minimum withdrawal: 0.01 SWAP.BTC.&#039;&#039;&#039; At current BTC prices that is roughly several hundred US dollars. You cannot withdraw less than this directly. Small amounts of SWAP.BTC are essentially trapped on Hive-Engine until you accumulate enough or trade them back.&lt;br /&gt;
* The withdrawal also pays the Bitcoin network mining fee plus a gateway fee, which can be significant for small amounts.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Workaround for small amounts:&#039;&#039;&#039; If you have less than 0.01 SWAP.BTC and want it as real BTC, the practical route is:&lt;br /&gt;
&lt;br /&gt;
# Sell SWAP.BTC → SWAP.HIVE on the Hive-Engine market (Step 4 above).&lt;br /&gt;
# Withdraw SWAP.HIVE → HIVE (Step 5 above).&lt;br /&gt;
# Send HIVE to a centralised exchange that lists HIVE (e.g. Binance, MEXC, etc. — check current listings).&lt;br /&gt;
# Trade HIVE for BTC on that exchange.&lt;br /&gt;
# Withdraw BTC from the exchange to your own Bitcoin wallet.&lt;br /&gt;
&lt;br /&gt;
This adds extra trading fees and exchange withdrawal fees, but it is the only viable path when your SWAP.BTC balance is below the 0.01 minimum.&lt;br /&gt;
&lt;br /&gt;
For our $10 / 1000-HIVE example amounts, &#039;&#039;&#039;you will not have anywhere near 0.01 SWAP.BTC.&#039;&#039;&#039; Treat the SWAP.BTC purchase as an exercise / way to gain BTC price exposure inside Hive-Engine, not as a way to actually move BTC onto the Bitcoin network.&lt;br /&gt;
&lt;br /&gt;
== Fee summary for the full round trip ==&lt;br /&gt;
&lt;br /&gt;
For the simple hive-engine.com path described above:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Stage !! Approximate fee&lt;br /&gt;
|-&lt;br /&gt;
| HBD → HIVE (swap.app) || 0.1%&lt;br /&gt;
|-&lt;br /&gt;
| HIVE → SWAP.HIVE (hive-engine.com deposit) || 0.75%&lt;br /&gt;
|-&lt;br /&gt;
| SWAP.HIVE → SWAP.BTC (market trade) || 1%&lt;br /&gt;
|-&lt;br /&gt;
| SWAP.BTC → SWAP.HIVE (market trade) || 1%&lt;br /&gt;
|-&lt;br /&gt;
| SWAP.HIVE → HIVE (hive-engine.com withdrawal) || 0.75%&lt;br /&gt;
|-&lt;br /&gt;
| HIVE → HBD (swap.app) || 0.1%&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Total round trip&#039;&#039;&#039; || &#039;&#039;&#039;~3.7%&#039;&#039;&#039; (plus market spread)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Plus you are exposed to BTC price movement the whole time SWAP.BTC sits in your wallet. Don&#039;t expect to round-trip $10 and come out ahead — the fees alone will eat about 37 cents.&lt;br /&gt;
&lt;br /&gt;
== Things that go wrong ==&lt;br /&gt;
&lt;br /&gt;
; &amp;quot;I deposited HIVE and nothing showed up in Hive-Engine.&amp;quot;&lt;br /&gt;
: Wait 2–3 minutes and refresh. If still nothing, check that the memo on the original transfer was the one Hive-Engine generated. Look up your transaction on hiveblocks.com to verify it sent successfully. Contact @honey-swap on Hive Discord if it has been more than an hour.&lt;br /&gt;
&lt;br /&gt;
; &amp;quot;My SWAP.BTC trade is sitting as an open order, not filling.&amp;quot;&lt;br /&gt;
: You set your buy price too low (or sell price too high). Either wait, or cancel the order and re-place it at a price that matches an existing counter-order.&lt;br /&gt;
&lt;br /&gt;
; &amp;quot;The withdraw button is rejecting my SWAP.BTC withdrawal.&amp;quot;&lt;br /&gt;
: You are likely below the 0.01 SWAP.BTC minimum. See the &amp;quot;Important: getting actual BTC out&amp;quot; section above for the workaround.&lt;br /&gt;
&lt;br /&gt;
; &amp;quot;Keychain didn&#039;t pop up.&amp;quot;&lt;br /&gt;
: Make sure the extension is installed, unlocked, and that you allowed the site permission. Refresh the page and try again.&lt;br /&gt;
&lt;br /&gt;
== Security reminders ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Never&#039;&#039;&#039; paste your master password or any private key into Hive-Engine, swap.app or any other website. Keychain handles signing for you. If a site asks you to type a key directly, it is either old/janky or a phishing site.&lt;br /&gt;
* Pegged SWAP tokens are only as trustworthy as their gateway operator. @honey-swap has been reliable for years, but reliable is not the same as risk-free.&lt;br /&gt;
* Always test with a small amount first. Move $1 before you move $1000.&lt;br /&gt;
* Bookmark the official URL (hive-engine.com). Do not click &amp;quot;Hive-Engine&amp;quot; links from random comments — phishing clones exist.&lt;br /&gt;
&lt;br /&gt;
== References ==&lt;br /&gt;
&lt;br /&gt;
* neopch (2025) — VIDEO TUTORIAL: How to Exchange Hive to SWAP.HIVE using Hive-Engine and PeakD. Confirms 0.75% deposit/withdrawal fee. &#039;&#039;hive.blog/hive-167922/@neopch/tdkoabkc&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== See also ==&lt;br /&gt;
&lt;br /&gt;
* [[Hive Blockchain Basics]]&lt;br /&gt;
* [[Setting up Hive Keychain]]&lt;br /&gt;
* [[How to keep your Hive keys safe]]&lt;br /&gt;
* [[Glossary of Hive terms]]&lt;br /&gt;
&lt;br /&gt;
[[Category:Hive]]&lt;br /&gt;
[[Category:Cryptocurrency]]&lt;br /&gt;
[[Category:Tutorials]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Resetting_Forgotten_GNOME_Keyring_in_Ubuntu_MATE&amp;diff=749</id>
		<title>Resetting Forgotten GNOME Keyring in Ubuntu MATE</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Resetting_Forgotten_GNOME_Keyring_in_Ubuntu_MATE&amp;diff=749"/>
		<updated>2026-04-30T23:18:15Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;== Resetting Forgotten GNOME Keyring ==  If you forget your keyring password or see the error &amp;#039;&amp;#039;&amp;quot;OS keyring is not available for encryption,&amp;quot;&amp;#039;&amp;#039; you must reset the local keyring storage.  === Method 1: Terminal (Recommended) === This deletes existing keyring files so the system can generate a fresh one. # Open the terminal. # Run the following command: #: &amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;rm ~/.local/share/keyrings/*.keyring&amp;lt;/syntaxhighlight&amp;gt; # &amp;#039;&amp;#039;&amp;#039;Log out&amp;#039;&amp;#039;&amp;#039; and log back in. #...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Resetting Forgotten GNOME Keyring ==&lt;br /&gt;
&lt;br /&gt;
If you forget your keyring password or see the error &#039;&#039;&amp;quot;OS keyring is not available for encryption,&amp;quot;&#039;&#039; you must reset the local keyring storage.&lt;br /&gt;
&lt;br /&gt;
=== Method 1: Terminal (Recommended) ===&lt;br /&gt;
This deletes existing keyring files so the system can generate a fresh one.&lt;br /&gt;
# Open the terminal.&lt;br /&gt;
# Run the following command:&lt;br /&gt;
#: &amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;rm ~/.local/share/keyrings/*.keyring&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
# &#039;&#039;&#039;Log out&#039;&#039;&#039; and log back in.&lt;br /&gt;
# When prompted by an app (like Chrome or VS Code), enter a new password. &lt;br /&gt;
#* &#039;&#039;&#039;Note:&#039;&#039;&#039; Use your system login password if you want it to unlock automatically.&lt;br /&gt;
&lt;br /&gt;
=== Method 2: Graphical Interface ===&lt;br /&gt;
# Open &#039;&#039;&#039;Passwords and Keys&#039;&#039;&#039; (Seahorse) from the application menu.&lt;br /&gt;
# Right-click the &#039;&#039;&#039;Login&#039;&#039;&#039; or &#039;&#039;&#039;Default&#039;&#039;&#039; keyring in the left sidebar.&lt;br /&gt;
# Select &#039;&#039;&#039;Delete&#039;&#039;&#039;.&lt;br /&gt;
# Restart the session.&lt;br /&gt;
&lt;br /&gt;
=== Fixing &amp;quot;Keyring Not Available&amp;quot; Errors ===&lt;br /&gt;
If the keyring is installed but not starting, ensure the following packages are present:&lt;br /&gt;
* &amp;lt;code&amp;gt;gnome-keyring&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;libsecret-1-0&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To install them:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo apt update &amp;amp;&amp;amp; sudo apt install gnome-keyring libsecret-1-0&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
[[Category:Troubleshooting]]&lt;br /&gt;
[[Category:Ubuntu MATE]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Delegate_Hive_Power_(HP)_to_Another_Account&amp;diff=748</id>
		<title>Delegate Hive Power (HP) to Another Account</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Delegate_Hive_Power_(HP)_to_Another_Account&amp;diff=748"/>
		<updated>2026-04-27T11:02:17Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;= Delegate Hive Power (HP) to Another Account =  &amp;#039;&amp;#039;&amp;#039;CompleteNoobs Walkthrough&amp;#039;&amp;#039;&amp;#039;   &amp;#039;&amp;#039;How to lend your Hive Power (HP) to another account safely and easily&amp;#039;&amp;#039;  == What is HP Delegation? == Hive Power (HP) is your staked HIVE. When you &amp;#039;&amp;#039;&amp;#039;delegate&amp;#039;&amp;#039;&amp;#039; HP to another account, you are temporarily lending them your voting power and Resource Credits (RC).    - You still &amp;#039;&amp;#039;&amp;#039;own&amp;#039;&amp;#039;&amp;#039; the HP 100%. - The other person can use the voting power to upvote posts. - You can take it back when...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Delegate Hive Power (HP) to Another Account =&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;CompleteNoobs Walkthrough&#039;&#039;&#039;  &lt;br /&gt;
&#039;&#039;How to lend your Hive Power (HP) to another account safely and easily&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== What is HP Delegation? ==&lt;br /&gt;
Hive Power (HP) is your staked HIVE. When you &#039;&#039;&#039;delegate&#039;&#039;&#039; HP to another account, you are temporarily lending them your voting power and Resource Credits (RC).  &lt;br /&gt;
&lt;br /&gt;
- You still &#039;&#039;&#039;own&#039;&#039;&#039; the HP 100%.&lt;br /&gt;
- The other person can use the voting power to upvote posts.&lt;br /&gt;
- You can take it back whenever you want (undelegate).&lt;br /&gt;
- This is very common on Hive to support projects, friends, curators, or to rent voting power.&lt;br /&gt;
&lt;br /&gt;
== Why Would You Do This? ==&lt;br /&gt;
- Help a friend or project get more voting power&lt;br /&gt;
- Support a curation team or witness&lt;br /&gt;
- Earn a small fee by renting your HP (some people do this)&lt;br /&gt;
- Move voting power between your own accounts&lt;br /&gt;
&lt;br /&gt;
== Important Warnings (Read This First) ==&lt;br /&gt;
&#039;&#039;&#039;⚠️ Only delegate what you are comfortable lending.&#039;&#039;&#039;  &lt;br /&gt;
&#039;&#039;&#039;⚠️ Never share your private keys or posting key.&#039;&#039;&#039;  &lt;br /&gt;
&#039;&#039;&#039;⚠️ Always double-check the &amp;quot;To&amp;quot; account name before confirming.&#039;&#039;&#039;  &lt;br /&gt;
&#039;&#039;&#039;⚠️ Start with a small test amount (e.g. 10 HP) the first time.&#039;&#039;&#039;  &lt;br /&gt;
&#039;&#039;&#039;⚠️ Use trusted interfaces only (PeakD or Hive Keychain recommended).&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
- A Hive account with some Hive Power (HP)&lt;br /&gt;
- Either:&lt;br /&gt;
  - Hive Keychain browser extension (recommended), &#039;&#039;&#039;or&#039;&#039;&#039;&lt;br /&gt;
  - Access to PeakD.com&lt;br /&gt;
&lt;br /&gt;
== Method 1: Easiest – Using PeakD.com (Web Interface) ==&lt;br /&gt;
&lt;br /&gt;
=== Step 1: Go to PeakD ===&lt;br /&gt;
# Open your browser and go to: [https://peakd.com/ https://peakd.com/]&lt;br /&gt;
# Click &#039;&#039;&#039;Login&#039;&#039;&#039; (top right) and log in with &#039;&#039;&#039;Hive Keychain&#039;&#039;&#039; (easiest) or your posting/active key.&lt;br /&gt;
&lt;br /&gt;
=== Step 2: Open Your Wallet ===&lt;br /&gt;
# Click on your username (top right) → &#039;&#039;&#039;Wallet&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
=== Step 3: Start the Delegation ===&lt;br /&gt;
# In the wallet, look for the big blue button that says &#039;&#039;&#039;Delegate HP&#039;&#039;&#039; (or &amp;quot;Power Delegation&amp;quot;).&lt;br /&gt;
# If you don&#039;t see it immediately, scroll down to the &#039;&#039;&#039;&amp;quot;Delegations&amp;quot;&#039;&#039;&#039; section and click &#039;&#039;&#039;Delegate&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
=== Step 4: Fill in the Details ===&lt;br /&gt;
# In the popup/window that appears:&lt;br /&gt;
#* &#039;&#039;&#039;To:&#039;&#039;&#039; → Type the exact Hive username you want to delegate to (e.g. &amp;lt;code&amp;gt;@completenoobs&amp;lt;/code&amp;gt;)&lt;br /&gt;
#* &#039;&#039;&#039;Amount:&#039;&#039;&#039; → Enter how much HP you want to delegate (you can use decimals, e.g. &amp;lt;code&amp;gt;50.123&amp;lt;/code&amp;gt;)&lt;br /&gt;
#* (Optional) Add a memo if you want&lt;br /&gt;
&lt;br /&gt;
=== Step 5: Confirm and Send ===&lt;br /&gt;
# Review everything carefully.&lt;br /&gt;
# Click &#039;&#039;&#039;Delegate&#039;&#039;&#039; or &#039;&#039;&#039;Confirm&#039;&#039;&#039;.&lt;br /&gt;
# Approve the transaction in Hive Keychain (or enter your active key if using manual login).&lt;br /&gt;
# You should see a green success message.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Done!&#039;&#039;&#039; The HP is now delegated.&lt;br /&gt;
&lt;br /&gt;
== Method 2: Fastest – Using Hive Keychain Extension (Recommended) ==&lt;br /&gt;
&lt;br /&gt;
This is the quickest way once you have the extension installed.&lt;br /&gt;
&lt;br /&gt;
=== Step 1: Install Hive Keychain (if you haven&#039;t) ===&lt;br /&gt;
# Go to the Chrome Web Store (or Firefox Add-ons) and search for &#039;&#039;&#039;&amp;quot;Hive Keychain&amp;quot;&#039;&#039;&#039;.&lt;br /&gt;
# Install it and set it up with your Hive account.&lt;br /&gt;
&lt;br /&gt;
=== Step 2: Open Keychain ===&lt;br /&gt;
# Click the little Hive Keychain icon in your browser toolbar.&lt;br /&gt;
# Make sure you are logged into the account that has the HP.&lt;br /&gt;
&lt;br /&gt;
=== Step 3: Choose Delegate ===&lt;br /&gt;
# In the Keychain popup, click the &#039;&#039;&#039;Delegate&#039;&#039;&#039; button (it usually has a little arrow icon).&lt;br /&gt;
&lt;br /&gt;
=== Step 4: Enter Details ===&lt;br /&gt;
# Fill in:&lt;br /&gt;
#* &#039;&#039;&#039;Delegate to:&#039;&#039;&#039; → The username (without the @)&lt;br /&gt;
#* &#039;&#039;&#039;Amount:&#039;&#039;&#039; → How much HP to send&lt;br /&gt;
# Click &#039;&#039;&#039;Next&#039;&#039;&#039; → Review → &#039;&#039;&#039;Confirm&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Done!&#039;&#039;&#039; Super fast.&lt;br /&gt;
&lt;br /&gt;
== How to Check Your Current Delegations ==&lt;br /&gt;
&lt;br /&gt;
=== On PeakD ===&lt;br /&gt;
# Go to your Wallet → scroll down to the &#039;&#039;&#039;Delegations&#039;&#039;&#039; tab.&lt;br /&gt;
# You will see two sections:&lt;br /&gt;
#* &#039;&#039;&#039;Outgoing&#039;&#039;&#039; (what you have delegated to others)&lt;br /&gt;
#* &#039;&#039;&#039;Incoming&#039;&#039;&#039; (what others have delegated to you)&lt;br /&gt;
&lt;br /&gt;
=== On Hive Keychain ===&lt;br /&gt;
# Click the Keychain icon → &#039;&#039;&#039;Delegations&#039;&#039;&#039; tab.&lt;br /&gt;
&lt;br /&gt;
== How to Undelegate (Take Your HP Back) ==&lt;br /&gt;
&lt;br /&gt;
The process is almost identical to delegating:&lt;br /&gt;
&lt;br /&gt;
1. Go back to PeakD Wallet or Hive Keychain.&lt;br /&gt;
2. Find the delegation you want to cancel.&lt;br /&gt;
3. Click &#039;&#039;&#039;Undelegate&#039;&#039;&#039; (or set the amount to 0).&lt;br /&gt;
4. Confirm the transaction.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Note:&#039;&#039;&#039; On Hive, undelegation usually returns the power within a few minutes to a few hours (much faster than the old Steem 5-day cooldown).&lt;br /&gt;
&lt;br /&gt;
== Advanced: Using the Command Line (CLI) ==&lt;br /&gt;
&lt;br /&gt;
Only do this if you are comfortable with the terminal.&lt;br /&gt;
&lt;br /&gt;
Using &amp;lt;code&amp;gt;beem&amp;lt;/code&amp;gt; Python library (example):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
beempy delegate @youraccount @targetaccount 100 HP&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Or using the official hived CLI (more advanced).&lt;br /&gt;
== Common Questions ==&lt;br /&gt;
* &#039;&#039;&#039;Q: Can I delegate all my HP?&#039;&#039;&#039;&lt;br /&gt;
A: Yes, but leave a little for yourself so you can still make transactions (you need some RC).&lt;br /&gt;
* &#039;&#039;&#039;Q: Do I still earn rewards on delegated HP?&#039;&#039;&#039;&lt;br /&gt;
A: Yes — you still own the HP and continue to receive the 10% annual inflation reward on it. Only the &#039;&#039;&#039;voting power&#039;&#039;&#039; is lent.&lt;br /&gt;
* &#039;&#039;&#039;Q: Is there a fee?&#039;&#039;&#039;&lt;br /&gt;
A: No delegation fee on Hive (only tiny blockchain resource cost).&lt;br /&gt;
*&#039;&#039;&#039;Q: Can the person I delegate to steal my HP?&#039;&#039;&#039;&lt;br /&gt;
A: No. They can only use the voting power. You always keep ownership.&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Main_Page&amp;diff=747</id>
		<title>Main Page</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Main_Page&amp;diff=747"/>
		<updated>2026-04-27T01:31:37Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* In Concept development Mode - a wiki you can download */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;br /&gt;
=In Concept development Mode - a wiki you can download=&lt;br /&gt;
&lt;br /&gt;
*  [https://www.completenoobs.com/noobs/V4call-v0.11| cnoobs.com on hold while working on v4call]&lt;br /&gt;
*  https://github.com/CompleteNoobs/v4call&lt;br /&gt;
&lt;br /&gt;
= CompleteNoobs: A Downloadable Wiki for Reproducible Computer Tutorials =&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;CompleteNoobs&#039;&#039;&#039; is a open-source wiki offering free, reproducible computer science tutorials. &lt;br /&gt;
&lt;br /&gt;
* [[Ubuntu2404_Install_Docker_and_Docker_Compose| Download the wiki as a &#039;&#039;&#039;Docker image&#039;&#039;&#039; to run locally on your computer or fork it to contribute and share knowledge.]]&lt;br /&gt;
&lt;br /&gt;
== About CompleteNoobs ==&lt;br /&gt;
&lt;br /&gt;
Our mission is to provide accessible, libre-licensed resources for hobbyists, sysadmins, students, teachers, and computer science enthusiasts. All content is available under a &#039;&#039;&#039;Creative Commons BY-NC-SA&#039;&#039;&#039; license, ensuring the freedoms to:&lt;br /&gt;
* Read&lt;br /&gt;
* Edit/Modify&lt;br /&gt;
* Copy&lt;br /&gt;
* Share freely&lt;br /&gt;
&lt;br /&gt;
Download the wiki as an XML file at [https://xml.completenoobs.com xml.completenoobs.com] or access data-heavy content (images, videos, audio) via &#039;&#039;&#039;IPFS&#039;&#039;&#039; hashes.&lt;br /&gt;
&lt;br /&gt;
== Get Started ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Download the Wiki&#039;&#039;&#039;: Run CompleteNoobs locally using our [[Local_CompleteNoobs_Wiki|Manually install guides or Docker image]].&lt;br /&gt;
* &#039;&#039;&#039;Host Your Own&#039;&#039;&#039;: Set up your own MediaWiki instance with our guide: [[Host_Your_Own_Mediawiki_Online|Host Your Own MediaWiki Online]].&lt;br /&gt;
&lt;br /&gt;
== CompleteNoobs Blockchain Project ==&lt;br /&gt;
&lt;br /&gt;
As Noobs we want to learn more about bitcoin, without losing any real bitcoin&lt;br /&gt;
&lt;br /&gt;
First we are learning how to fork bitcoin version 0.14.3, best way to learn is by tinkering with it.&lt;br /&gt;
* [[N33Bcoin| n33b.com]]&lt;br /&gt;
&lt;br /&gt;
== Essential Links ==&lt;br /&gt;
&lt;br /&gt;
* [[Main_Index|Main Index Page]]&lt;br /&gt;
* [[Special:AllPages|All Pages]]&lt;br /&gt;
* [[Wiki_Basic_Syntax|Wiki Basic Syntax]]&lt;br /&gt;
* [[COMPLETENOOBS_FUNDING|Support Us]]&lt;br /&gt;
&lt;br /&gt;
== Licenses ==&lt;br /&gt;
[[LICENCE_HEADERS]]&lt;br /&gt;
All content is libre-licensed for free copying, modification, and distribution. Add a license header to your contributions using:&lt;br /&gt;
&amp;lt;pre&amp;gt;{{:LICENCE_HEADER_CC0}}&amp;lt;/pre&amp;gt;&lt;br /&gt;
{{:LICENCE_HEADER_CC0}}&lt;br /&gt;
&lt;br /&gt;
== Disclaimer ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
&#039;&#039;&#039;&#039;&#039;DISCLAIMER:&#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
Content on CompleteNoobs is for informational and educational purposes only. We make no warranties about accuracy or reliability. Use at your own risk. Links to external sites are not endorsements, and we are not responsible for their content or availability. The site may be temporarily unavailable due to technical issues beyond our control.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Special:ContributionScores/10/5}}&lt;br /&gt;
{{Special:PopularPages/10}}&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Main_Page&amp;diff=746</id>
		<title>Main Page</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Main_Page&amp;diff=746"/>
		<updated>2026-04-27T01:11:48Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* In Concept development Mode - a wiki you can download */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;br /&gt;
=In Concept development Mode - a wiki you can download=&lt;br /&gt;
&lt;br /&gt;
*  [https://www.completenoobs.com/noobs/V4call-v0.11| cnoobs.com on hold while working on v4call]&lt;br /&gt;
&lt;br /&gt;
= CompleteNoobs: A Downloadable Wiki for Reproducible Computer Tutorials =&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;CompleteNoobs&#039;&#039;&#039; is a open-source wiki offering free, reproducible computer science tutorials. &lt;br /&gt;
&lt;br /&gt;
* [[Ubuntu2404_Install_Docker_and_Docker_Compose| Download the wiki as a &#039;&#039;&#039;Docker image&#039;&#039;&#039; to run locally on your computer or fork it to contribute and share knowledge.]]&lt;br /&gt;
&lt;br /&gt;
== About CompleteNoobs ==&lt;br /&gt;
&lt;br /&gt;
Our mission is to provide accessible, libre-licensed resources for hobbyists, sysadmins, students, teachers, and computer science enthusiasts. All content is available under a &#039;&#039;&#039;Creative Commons BY-NC-SA&#039;&#039;&#039; license, ensuring the freedoms to:&lt;br /&gt;
* Read&lt;br /&gt;
* Edit/Modify&lt;br /&gt;
* Copy&lt;br /&gt;
* Share freely&lt;br /&gt;
&lt;br /&gt;
Download the wiki as an XML file at [https://xml.completenoobs.com xml.completenoobs.com] or access data-heavy content (images, videos, audio) via &#039;&#039;&#039;IPFS&#039;&#039;&#039; hashes.&lt;br /&gt;
&lt;br /&gt;
== Get Started ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Download the Wiki&#039;&#039;&#039;: Run CompleteNoobs locally using our [[Local_CompleteNoobs_Wiki|Manually install guides or Docker image]].&lt;br /&gt;
* &#039;&#039;&#039;Host Your Own&#039;&#039;&#039;: Set up your own MediaWiki instance with our guide: [[Host_Your_Own_Mediawiki_Online|Host Your Own MediaWiki Online]].&lt;br /&gt;
&lt;br /&gt;
== CompleteNoobs Blockchain Project ==&lt;br /&gt;
&lt;br /&gt;
As Noobs we want to learn more about bitcoin, without losing any real bitcoin&lt;br /&gt;
&lt;br /&gt;
First we are learning how to fork bitcoin version 0.14.3, best way to learn is by tinkering with it.&lt;br /&gt;
* [[N33Bcoin| n33b.com]]&lt;br /&gt;
&lt;br /&gt;
== Essential Links ==&lt;br /&gt;
&lt;br /&gt;
* [[Main_Index|Main Index Page]]&lt;br /&gt;
* [[Special:AllPages|All Pages]]&lt;br /&gt;
* [[Wiki_Basic_Syntax|Wiki Basic Syntax]]&lt;br /&gt;
* [[COMPLETENOOBS_FUNDING|Support Us]]&lt;br /&gt;
&lt;br /&gt;
== Licenses ==&lt;br /&gt;
[[LICENCE_HEADERS]]&lt;br /&gt;
All content is libre-licensed for free copying, modification, and distribution. Add a license header to your contributions using:&lt;br /&gt;
&amp;lt;pre&amp;gt;{{:LICENCE_HEADER_CC0}}&amp;lt;/pre&amp;gt;&lt;br /&gt;
{{:LICENCE_HEADER_CC0}}&lt;br /&gt;
&lt;br /&gt;
== Disclaimer ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
&#039;&#039;&#039;&#039;&#039;DISCLAIMER:&#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
Content on CompleteNoobs is for informational and educational purposes only. We make no warranties about accuracy or reliability. Use at your own risk. Links to external sites are not endorsements, and we are not responsible for their content or availability. The site may be temporarily unavailable due to technical issues beyond our control.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Special:ContributionScores/10/5}}&lt;br /&gt;
{{Special:PopularPages/10}}&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=HTML_Expanding_info_box&amp;diff=745</id>
		<title>HTML Expanding info box</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=HTML_Expanding_info_box&amp;diff=745"/>
		<updated>2026-04-27T01:08:48Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Full Example Page */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== How to Add an Expanding Info Box (Simple Guide)==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Feature&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| Method&lt;br /&gt;
| &amp;amp;lt;details&amp;amp;gt; + &amp;amp;lt;summary&amp;amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| JavaScript&lt;br /&gt;
| Not needed&lt;br /&gt;
|-&lt;br /&gt;
| Mobile Support&lt;br /&gt;
| Yes&lt;br /&gt;
|-&lt;br /&gt;
| Difficulty&lt;br /&gt;
| Very Easy&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Basic Example===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;details&amp;gt;&lt;br /&gt;
  &amp;lt;summary&amp;gt;What you will like&amp;lt;/summary&amp;gt;&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;div&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;This content is hidden until clicked.&amp;lt;/p&amp;gt;&lt;br /&gt;
 &amp;lt;code&amp;gt;print &amp;quot;hello content&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/details&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Optional CSS Styling===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
body {&lt;br /&gt;
  font-family: sans-serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
details {&lt;br /&gt;
  border: 1px solid #aaa;&lt;br /&gt;
  margin: 20px 0;&lt;br /&gt;
  padding: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
summary {&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Dark Mode (Optional)===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
@media (prefers-color-scheme: dark) {&lt;br /&gt;
  details {&lt;br /&gt;
    background: #222;&lt;br /&gt;
    color: #ddd;&lt;br /&gt;
    border: 1px solid #555;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  summary {&lt;br /&gt;
    color: #fff;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Full Example Page===&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br /&gt;
&amp;lt;html&amp;gt;&lt;br /&gt;
&amp;lt;head&amp;gt;&lt;br /&gt;
  &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;title&amp;gt;Example&amp;lt;/title&amp;gt;&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;style&amp;gt;&lt;br /&gt;
    body {&lt;br /&gt;
      font-family: sans-serif;&lt;br /&gt;
      max-width: 800px;&lt;br /&gt;
      margin: 40px auto;&lt;br /&gt;
      padding: 20px;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    details {&lt;br /&gt;
      border: 1px solid #aaa;&lt;br /&gt;
      margin: 20px 0;&lt;br /&gt;
      padding: 5px;&lt;br /&gt;
      background: #f9f9f9;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    summary {&lt;br /&gt;
      font-weight: bold;&lt;br /&gt;
      cursor: pointer;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    /* Dark Mode */&lt;br /&gt;
    @media (prefers-color-scheme: dark) {&lt;br /&gt;
      body {&lt;br /&gt;
        background: #111;&lt;br /&gt;
        color: #ddd;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      details {&lt;br /&gt;
        background: #222;&lt;br /&gt;
        border: 1px solid #555;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      summary {&lt;br /&gt;
        color: #fff;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
  &amp;lt;/style&amp;gt;&lt;br /&gt;
&amp;lt;/head&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;body&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;h1&amp;gt;Example Page&amp;lt;/h1&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;details&amp;gt;&lt;br /&gt;
  &amp;lt;summary&amp;gt;What you will like&amp;lt;/summary&amp;gt;&lt;br /&gt;
  &amp;lt;div&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;Hidden content here...&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;code&amp;gt;print &amp;quot;hello content&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/details&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/body&amp;gt;&lt;br /&gt;
&amp;lt;/html&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;!-- bug hunt --&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Another example:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br /&gt;
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;head&amp;gt;&lt;br /&gt;
  &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1.0&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;title&amp;gt;completenoobs.com&amp;lt;/title&amp;gt;&lt;br /&gt;
  &amp;lt;style&amp;gt;&lt;br /&gt;
    body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; line-height: 1.6; }&lt;br /&gt;
    &lt;br /&gt;
    details {&lt;br /&gt;
      border: 1px solid #ddd;&lt;br /&gt;
      border-radius: 8px;&lt;br /&gt;
      margin: 25px 0;&lt;br /&gt;
      background: #f9f9f9;&lt;br /&gt;
    }&lt;br /&gt;
    summary {&lt;br /&gt;
      padding: 15px 20px;&lt;br /&gt;
      font-weight: bold;&lt;br /&gt;
      cursor: pointer;&lt;br /&gt;
      background: #f0f0f0;&lt;br /&gt;
      border-radius: 8px 8px 0 0;&lt;br /&gt;
    }&lt;br /&gt;
    .mw-collapsible-content { padding: 20px; }&lt;br /&gt;
&lt;br /&gt;
    /* Dark Mode */&lt;br /&gt;
    @media (prefers-color-scheme: dark) {&lt;br /&gt;
      details { border: 1px solid #555; background: #1e1e1e; color: #ddd; }&lt;br /&gt;
      summary { background: #2a2a2a; color: #eee; }&lt;br /&gt;
      .mw-collapsible-content { color: #ccc; }&lt;br /&gt;
    }&lt;br /&gt;
  &amp;lt;/style&amp;gt;&lt;br /&gt;
&amp;lt;/head&amp;gt;&lt;br /&gt;
&amp;lt;body&amp;gt;&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;h1&amp;gt;Welcome to completenoobs.com&amp;lt;/h1&amp;gt;&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;details&amp;gt;&lt;br /&gt;
    &amp;lt;summary&amp;gt;What you will like&amp;lt;/summary&amp;gt;&lt;br /&gt;
    &amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
      &amp;lt;p&amp;gt;Put all your hidden content here...&amp;lt;/p&amp;gt;&lt;br /&gt;
        &amp;lt;code&amp;gt;print &amp;quot;hello content&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
    &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;/details&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/body&amp;gt;&lt;br /&gt;
&amp;lt;/html&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Tips===&lt;br /&gt;
&lt;br /&gt;
* To open by default: &amp;lt;code&amp;gt;&amp;amp;lt;details open&amp;amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* Duplicate the block to create more sections&lt;br /&gt;
* Works without CSS (just looks basic)&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
The CSS explained:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;What is CSS?&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
CSS is what makes things look nice.&lt;br /&gt;
&lt;br /&gt;
* HTML = the structure (the box)&lt;br /&gt;
* CSS = the style (colors, spacing, fonts)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;The Page (body)&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
body {&lt;br /&gt;
  font-family: sans-serif;&lt;br /&gt;
  max-width: 800px;&lt;br /&gt;
  margin: 40px auto;&lt;br /&gt;
  padding: 20px;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This controls the whole page:&lt;br /&gt;
&lt;br /&gt;
* Changes the font&lt;br /&gt;
* Keeps the page centered&lt;br /&gt;
* Adds space so things are easy to read&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;The Box (details)&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
details {&lt;br /&gt;
  border: 1px solid #aaa;&lt;br /&gt;
  margin: 20px 0;&lt;br /&gt;
  padding: 5px;&lt;br /&gt;
  background: #f9f9f9;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is the expandable box:&lt;br /&gt;
&lt;br /&gt;
* Border = thin line around it&lt;br /&gt;
* Margin = space outside the box&lt;br /&gt;
* Padding = space inside the box&lt;br /&gt;
* Background = box color&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;The Clickable Title (summary)&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
summary {&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is what you click:&lt;br /&gt;
&lt;br /&gt;
* Bold text&lt;br /&gt;
* Mouse turns into a hand&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Dark Mode (automatic)&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
@media (prefers-color-scheme: dark) {&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This means:&lt;br /&gt;
&lt;br /&gt;
&amp;quot;If the user is using dark mode, use different colors&amp;quot;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Dark Mode Colours&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
body {&lt;br /&gt;
  background: #111;&lt;br /&gt;
  color: #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
details {&lt;br /&gt;
  background: #222;&lt;br /&gt;
  border: 1px solid #555;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
summary {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This makes:&lt;br /&gt;
&lt;br /&gt;
* Dark background&lt;br /&gt;
* Light text&lt;br /&gt;
* Darker boxes&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;How to Change Colours&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Darker background → change &amp;lt;code&amp;gt;#111&amp;lt;/code&amp;gt;&lt;br /&gt;
* Lighter text → change &amp;lt;code&amp;gt;#ddd&amp;lt;/code&amp;gt;&lt;br /&gt;
* Box color → change &amp;lt;code&amp;gt;#222&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Examples:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;#000&amp;lt;/code&amp;gt; = pure black&lt;br /&gt;
* &amp;lt;code&amp;gt;#fff&amp;lt;/code&amp;gt; = pure white&lt;br /&gt;
* &amp;lt;code&amp;gt;#1a1a1a&amp;lt;/code&amp;gt; = softer black&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Force Dark Mode (always on)&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Remove the &amp;lt;code&amp;gt;@media&amp;lt;/code&amp;gt; line and just use:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
body {&lt;br /&gt;
  background: #111;&lt;br /&gt;
  color: #ddd;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Super Simple Summary&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* CSS = makes things look nice&lt;br /&gt;
* details = the box&lt;br /&gt;
* summary = the clickable title&lt;br /&gt;
* dark mode = automatic color switch&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;!-- AI&#039;s used in creating page, grok and chatgpt 27-04-2026 --&amp;gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=HTML_Expanding_info_box&amp;diff=744</id>
		<title>HTML Expanding info box</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=HTML_Expanding_info_box&amp;diff=744"/>
		<updated>2026-04-27T01:02:11Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;== How to Add an Expanding Info Box (Simple Guide)==  {| class=&amp;quot;wikitable&amp;quot; |- ! Feature ! Value |- | Method | &amp;amp;lt;details&amp;amp;gt; + &amp;amp;lt;summary&amp;amp;gt; |- | JavaScript | Not needed |- | Mobile Support | Yes |- | Difficulty | Very Easy |}  === Basic Example===  &amp;lt;pre&amp;gt; &amp;lt;details&amp;gt;   &amp;lt;summary&amp;gt;What you will like&amp;lt;/summary&amp;gt;    &amp;lt;div&amp;gt;     &amp;lt;p&amp;gt;This content is hidden until clicked.&amp;lt;/p&amp;gt;  &amp;lt;code&amp;gt;print &amp;quot;hello content&amp;quot;&amp;lt;/code&amp;gt;   &amp;lt;/div&amp;gt;  &amp;lt;/details&amp;gt; &amp;lt;/pre&amp;gt;  === Optional CSS Styling===  &amp;lt;pre&amp;gt; body {...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== How to Add an Expanding Info Box (Simple Guide)==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Feature&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| Method&lt;br /&gt;
| &amp;amp;lt;details&amp;amp;gt; + &amp;amp;lt;summary&amp;amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| JavaScript&lt;br /&gt;
| Not needed&lt;br /&gt;
|-&lt;br /&gt;
| Mobile Support&lt;br /&gt;
| Yes&lt;br /&gt;
|-&lt;br /&gt;
| Difficulty&lt;br /&gt;
| Very Easy&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Basic Example===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;details&amp;gt;&lt;br /&gt;
  &amp;lt;summary&amp;gt;What you will like&amp;lt;/summary&amp;gt;&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;div&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;This content is hidden until clicked.&amp;lt;/p&amp;gt;&lt;br /&gt;
 &amp;lt;code&amp;gt;print &amp;quot;hello content&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/details&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Optional CSS Styling===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
body {&lt;br /&gt;
  font-family: sans-serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
details {&lt;br /&gt;
  border: 1px solid #aaa;&lt;br /&gt;
  margin: 20px 0;&lt;br /&gt;
  padding: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
summary {&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Dark Mode (Optional)===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
@media (prefers-color-scheme: dark) {&lt;br /&gt;
  details {&lt;br /&gt;
    background: #222;&lt;br /&gt;
    color: #ddd;&lt;br /&gt;
    border: 1px solid #555;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  summary {&lt;br /&gt;
    color: #fff;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Full Example Page===&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br /&gt;
&amp;lt;html&amp;gt;&lt;br /&gt;
&amp;lt;head&amp;gt;&lt;br /&gt;
  &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;title&amp;gt;Example&amp;lt;/title&amp;gt;&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;style&amp;gt;&lt;br /&gt;
    body {&lt;br /&gt;
      font-family: sans-serif;&lt;br /&gt;
      max-width: 800px;&lt;br /&gt;
      margin: 40px auto;&lt;br /&gt;
      padding: 20px;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    details {&lt;br /&gt;
      border: 1px solid #aaa;&lt;br /&gt;
      margin: 20px 0;&lt;br /&gt;
      padding: 5px;&lt;br /&gt;
      background: #f9f9f9;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    summary {&lt;br /&gt;
      font-weight: bold;&lt;br /&gt;
      cursor: pointer;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    /* Dark Mode */&lt;br /&gt;
    @media (prefers-color-scheme: dark) {&lt;br /&gt;
      body {&lt;br /&gt;
        background: #111;&lt;br /&gt;
        color: #ddd;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      details {&lt;br /&gt;
        background: #222;&lt;br /&gt;
        border: 1px solid #555;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      summary {&lt;br /&gt;
        color: #fff;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
  &amp;lt;/style&amp;gt;&lt;br /&gt;
&amp;lt;/head&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;body&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;h1&amp;gt;Example Page&amp;lt;/h1&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;details&amp;gt;&lt;br /&gt;
  &amp;lt;summary&amp;gt;What you will like&amp;lt;/summary&amp;gt;&lt;br /&gt;
  &amp;lt;div&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;Hidden content here...&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;code&amp;gt;print &amp;quot;hello content&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/details&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/body&amp;gt;&lt;br /&gt;
&amp;lt;/html&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Tips===&lt;br /&gt;
&lt;br /&gt;
* To open by default: &amp;lt;code&amp;gt;&amp;amp;lt;details open&amp;amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* Duplicate the block to create more sections&lt;br /&gt;
* Works without CSS (just looks basic)&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
The CSS explained:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;What is CSS?&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
CSS is what makes things look nice.&lt;br /&gt;
&lt;br /&gt;
* HTML = the structure (the box)&lt;br /&gt;
* CSS = the style (colors, spacing, fonts)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;The Page (body)&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
body {&lt;br /&gt;
  font-family: sans-serif;&lt;br /&gt;
  max-width: 800px;&lt;br /&gt;
  margin: 40px auto;&lt;br /&gt;
  padding: 20px;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This controls the whole page:&lt;br /&gt;
&lt;br /&gt;
* Changes the font&lt;br /&gt;
* Keeps the page centered&lt;br /&gt;
* Adds space so things are easy to read&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;The Box (details)&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
details {&lt;br /&gt;
  border: 1px solid #aaa;&lt;br /&gt;
  margin: 20px 0;&lt;br /&gt;
  padding: 5px;&lt;br /&gt;
  background: #f9f9f9;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is the expandable box:&lt;br /&gt;
&lt;br /&gt;
* Border = thin line around it&lt;br /&gt;
* Margin = space outside the box&lt;br /&gt;
* Padding = space inside the box&lt;br /&gt;
* Background = box color&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;The Clickable Title (summary)&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
summary {&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is what you click:&lt;br /&gt;
&lt;br /&gt;
* Bold text&lt;br /&gt;
* Mouse turns into a hand&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Dark Mode (automatic)&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
@media (prefers-color-scheme: dark) {&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This means:&lt;br /&gt;
&lt;br /&gt;
&amp;quot;If the user is using dark mode, use different colors&amp;quot;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Dark Mode Colours&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
body {&lt;br /&gt;
  background: #111;&lt;br /&gt;
  color: #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
details {&lt;br /&gt;
  background: #222;&lt;br /&gt;
  border: 1px solid #555;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
summary {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This makes:&lt;br /&gt;
&lt;br /&gt;
* Dark background&lt;br /&gt;
* Light text&lt;br /&gt;
* Darker boxes&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;How to Change Colours&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Darker background → change &amp;lt;code&amp;gt;#111&amp;lt;/code&amp;gt;&lt;br /&gt;
* Lighter text → change &amp;lt;code&amp;gt;#ddd&amp;lt;/code&amp;gt;&lt;br /&gt;
* Box color → change &amp;lt;code&amp;gt;#222&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Examples:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;#000&amp;lt;/code&amp;gt; = pure black&lt;br /&gt;
* &amp;lt;code&amp;gt;#fff&amp;lt;/code&amp;gt; = pure white&lt;br /&gt;
* &amp;lt;code&amp;gt;#1a1a1a&amp;lt;/code&amp;gt; = softer black&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Force Dark Mode (always on)&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Remove the &amp;lt;code&amp;gt;@media&amp;lt;/code&amp;gt; line and just use:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
body {&lt;br /&gt;
  background: #111;&lt;br /&gt;
  color: #ddd;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Super Simple Summary&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* CSS = makes things look nice&lt;br /&gt;
* details = the box&lt;br /&gt;
* summary = the clickable title&lt;br /&gt;
* dark mode = automatic color switch&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;!-- AI&#039;s used in creating page, grok and chatgpt 27-04-2026 --&amp;gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=V4call-v0.11&amp;diff=743</id>
		<title>V4call-v0.11</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=V4call-v0.11&amp;diff=743"/>
		<updated>2026-04-25T01:49:08Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;{{:LICENCE_HEADER_MIT}}   = v4call — Deploy Your Own Server on Ubuntu 24.04 with Docker =  From CompleteNoobs  This guide walks through deploying your own v4call server from scratch on a fresh Vultr Ubuntu 24.04 VPS — from first login to a working HTTPS video/audio calling service on your own domain, optionally federated with other v4call servers.  v4call is an open-source, decentralised video and audio calling platform that uses Hive blockchain for identity and HBD...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_MIT}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= v4call — Deploy Your Own Server on Ubuntu 24.04 with Docker =&lt;br /&gt;
&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
&lt;br /&gt;
This guide walks through deploying your own v4call server from scratch on a fresh Vultr Ubuntu 24.04 VPS — from first login to a working HTTPS video/audio calling service on your own domain, optionally federated with other v4call servers.&lt;br /&gt;
&lt;br /&gt;
v4call is an open-source, decentralised video and audio calling platform that uses Hive blockchain for identity and HBD micropayments. It supports custom Hive-Engine token payments, encrypted direct messages with persistent chat history, voice-only and video calls, and a free-market platform fee system. Fork it, run your own server, keep all your platform fees, join the federation.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Source code&#039;&#039;&#039;: [https://github.com/CompleteNoobs/v4call https://github.com/CompleteNoobs/v4call]&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;End result&#039;&#039;&#039;: A working v4call server at &amp;lt;code&amp;gt;https://call.yourdomain.com&amp;lt;/code&amp;gt; that you can log into with a Hive account, optionally connected to other v4call servers via federation.&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
&lt;br /&gt;
* [[#What_You_Need|1 What You Need]]&lt;br /&gt;
* [[#Step_1:_Create_Your_Vultr_VPS|2 Step 1: Create Your Vultr VPS]]&lt;br /&gt;
* [[#Step_2:_Point_Your_Domain_at_the_VPS|3 Step 2: Point Your Domain at the VPS]]&lt;br /&gt;
* [[#Step_3:_Log_into_Your_VPS|4 Step 3: Log into Your VPS]]&lt;br /&gt;
* [[#Step_4:_Update_the_Server|5 Step 4: Update the Server]]&lt;br /&gt;
* [[#Step_5:_Install_Docker|6 Step 5: Install Docker]]&lt;br /&gt;
* [[#Step_6:_Install_Git|7 Step 6: Install Git]]&lt;br /&gt;
* [[#Step_7:_Fork_and_Clone_the_Code|8 Step 7: Fork and Clone the Code]]&lt;br /&gt;
* [[#Step_8:_Configure_Your_Server_(.env_file)|9 Step 8: Configure Your Server (.env file)]]&lt;br /&gt;
* [[#Step_9:_Configure_Nginx_—_HTTP_Only_First|10 Step 9: Configure Nginx — HTTP Only First]]&lt;br /&gt;
* [[#Step_10:_Create_Data_Directories_and_Fix_Permissions|11 Step 10: Create Data Directories and Fix Permissions]]&lt;br /&gt;
* [[#Step_11:_Build_and_Start_the_Server|12 Step 11: Build and Start the Server]]&lt;br /&gt;
* [[#Step_12:_Get_Your_SSL_Certificate|13 Step 12: Get Your SSL Certificate]]&lt;br /&gt;
* [[#Step_13:_Enable_HTTPS_in_Nginx|14 Step 13: Enable HTTPS in Nginx]]&lt;br /&gt;
* [[#Step_14:_Set_Up_SSL_Auto-Renewal|15 Step 14: Set Up SSL Auto-Renewal]]&lt;br /&gt;
* [[#Step_15:_Test_Everything_is_Working|16 Step 15: Test Everything is Working]]&lt;br /&gt;
* [[#Step_16:_Set_Up_Your_Call_Rates_on_Hive|17 Step 16: Set Up Your Call Rates on Hive]]&lt;br /&gt;
* [[#Step_17:_Federation_Setup_(Optional)|18 Step 17: Federation Setup (Optional)]]&lt;br /&gt;
* [[#Feature_Guide:_What_Your_Server_Can_Do|19 Feature Guide: What Your Server Can Do]]&lt;br /&gt;
* [[#Admin_Configuration_Reference|20 Admin Configuration Reference]]&lt;br /&gt;
* [[#Updating_Your_Server|21 Updating Your Server]]&lt;br /&gt;
* [[#Common_Problems_and_Fixes|22 Common Problems and Fixes]]&lt;br /&gt;
* [[#Quick_Reference|23 Quick Reference]]&lt;br /&gt;
&lt;br /&gt;
== What You Need ==&lt;br /&gt;
&lt;br /&gt;
Before starting, make sure you have the following:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;A Vultr account&#039;&#039;&#039; — sign up at [https://vultr.com vultr.com] - please use are [https://www.vultr.com/?ref=7704739 Vultr Referral link] to help us cover server costs.&lt;br /&gt;
* &#039;&#039;&#039;A domain name&#039;&#039;&#039; with DNS access — e.g. &amp;lt;code&amp;gt;call.yourdomain.com&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;Two Hive accounts&#039;&#039;&#039; — one for your server identity (receives platform fees), one for escrow (holds caller funds during calls). Create free accounts at [https://signup.hive.io signup.hive.io]&lt;br /&gt;
* &#039;&#039;&#039;Hive Keychain browser extension&#039;&#039;&#039; — for login and payments. Install from [https://hive-keychain.com hive-keychain.com]&lt;br /&gt;
* &#039;&#039;&#039;A GitHub account&#039;&#039;&#039; — free at [https://github.com github.com]. You will fork the v4call project.&lt;br /&gt;
* &#039;&#039;&#039;A terminal&#039;&#039;&#039; — Mac: Terminal app. Windows: PowerShell or PuTTY.&lt;br /&gt;
&lt;br /&gt;
You do not need to know how to code. Every command can be copy-pasted exactly as shown.&lt;br /&gt;
&lt;br /&gt;
== Step 1: Create Your Vultr VPS ==&lt;br /&gt;
&lt;br /&gt;
# Log into [https://my.vultr.com my.vultr.com]&lt;br /&gt;
# Click &#039;&#039;&#039;Deploy New Server&#039;&#039;&#039;&lt;br /&gt;
# Choose &#039;&#039;&#039;Cloud Compute — Shared CPU&#039;&#039;&#039;&lt;br /&gt;
# Choose a location close to you&lt;br /&gt;
# Choose &#039;&#039;&#039;Ubuntu 24.04 LTS x64&#039;&#039;&#039;&lt;br /&gt;
# Choose the &#039;&#039;&#039;$6/month&#039;&#039;&#039; plan (1 CPU, 1GB RAM)&lt;br /&gt;
# Set Server Hostname to something like &amp;lt;code&amp;gt;v4call-server&amp;lt;/code&amp;gt;&lt;br /&gt;
# Click &#039;&#039;&#039;Deploy Now&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Wait ~60 seconds for it to start. Click the server to find:&lt;br /&gt;
* &#039;&#039;&#039;IP Address&#039;&#039;&#039; — looks like &amp;lt;code&amp;gt;123.456.789.012&amp;lt;/code&amp;gt; — write it down&lt;br /&gt;
* &#039;&#039;&#039;Password&#039;&#039;&#039; — click the eye icon — write it down&lt;br /&gt;
&lt;br /&gt;
== Step 2: Point Your Domain at the VPS ==&lt;br /&gt;
&lt;br /&gt;
Log into your DNS provider and add an A record:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Field !! Value&lt;br /&gt;
|-&lt;br /&gt;
| Type || A&lt;br /&gt;
|-&lt;br /&gt;
| Name || &amp;lt;code&amp;gt;call&amp;lt;/code&amp;gt; (or &amp;lt;code&amp;gt;@&amp;lt;/code&amp;gt; for root domain)&lt;br /&gt;
|-&lt;br /&gt;
| Value || Your VPS IP address&lt;br /&gt;
|-&lt;br /&gt;
| TTL || 300&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
DNS takes a few minutes to propagate. Check from your computer later:&lt;br /&gt;
&lt;br /&gt;
 nslookup call.yourdomain.com&lt;br /&gt;
&lt;br /&gt;
Must show your VPS IP before Step 12. You can continue with all other steps while waiting.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Log into Your VPS ==&lt;br /&gt;
&lt;br /&gt;
Open a terminal on your computer:&lt;br /&gt;
&lt;br /&gt;
 ssh root@YOUR_SERVER_IP&lt;br /&gt;
* NOTE: you can also do &amp;lt;code&amp;gt;root@your-domain.com&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Type &amp;lt;code&amp;gt;yes&amp;lt;/code&amp;gt; when asked about the fingerprint, then paste the password from Vultr (right-click to paste in most terminals).&lt;br /&gt;
&lt;br /&gt;
When you see &amp;lt;code&amp;gt;root@v4call-server:~#&amp;lt;/code&amp;gt; you are in.&lt;br /&gt;
&lt;br /&gt;
== Step 4: Update the Server ==&lt;br /&gt;
&lt;br /&gt;
 apt update &amp;amp;&amp;amp; apt upgrade -y&lt;br /&gt;
&lt;br /&gt;
Wait for it to complete (1-2 minutes).&lt;br /&gt;
&lt;br /&gt;
== Step 5: Install Docker ==&lt;br /&gt;
&lt;br /&gt;
Install Docker using the official installer script:&lt;br /&gt;
&lt;br /&gt;
 curl -fsSL https://get.docker.com | sh&lt;br /&gt;
&lt;br /&gt;
Verify:&lt;br /&gt;
&lt;br /&gt;
 docker --version&lt;br /&gt;
&lt;br /&gt;
Should show &amp;lt;code&amp;gt;Docker version 26.x.x&amp;lt;/code&amp;gt; or similar.&lt;br /&gt;
&lt;br /&gt;
Install Docker Compose plugin:&lt;br /&gt;
&lt;br /&gt;
 apt install -y docker-compose-plugin&lt;br /&gt;
&lt;br /&gt;
Verify:&lt;br /&gt;
&lt;br /&gt;
 docker compose version&lt;br /&gt;
&lt;br /&gt;
Should show &amp;lt;code&amp;gt;Docker Compose version v2.x.x&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Step 6: Install Git ==&lt;br /&gt;
&lt;br /&gt;
 apt install -y git&lt;br /&gt;
 git --version&lt;br /&gt;
&lt;br /&gt;
== Step 7: Fork and Clone the Code ==&lt;br /&gt;
&lt;br /&gt;
=== Clone onto your VPS ===&lt;br /&gt;
&lt;br /&gt;
 cd /opt&lt;br /&gt;
 git clone https://github.com/CompleteNoobs/v4call.git&lt;br /&gt;
 cd v4call&lt;br /&gt;
&lt;br /&gt;
==== Fork on GitHub - Optional ====&lt;br /&gt;
&lt;br /&gt;
Forking gives you your own copy of the code that you can customise — change the name, branding, fees — without affecting the original project.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://github.com/CompleteNoobs/v4call https://github.com/CompleteNoobs/v4call]&lt;br /&gt;
# Click the &#039;&#039;&#039;Fork&#039;&#039;&#039; button (top right of the page)&lt;br /&gt;
# Select your GitHub account as the destination&lt;br /&gt;
# Click &#039;&#039;&#039;Create fork&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
You now have your own copy at &amp;lt;code&amp;gt;https://github.com/YOURUSERNAME/v4call&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Clone onto your VPS ====&lt;br /&gt;
&lt;br /&gt;
 cd /opt&lt;br /&gt;
 git clone https://github.com/YOURUSERNAME/v4call.git&lt;br /&gt;
 cd v4call&lt;br /&gt;
&lt;br /&gt;
List the files to confirm:&lt;br /&gt;
&lt;br /&gt;
 ls&lt;br /&gt;
&lt;br /&gt;
You should see: &amp;lt;code&amp;gt;server.js  public/  Dockerfile  docker-compose.yml  nginx/  package.json  .env.example  README.md&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Worth knowing about the &amp;lt;code&amp;gt;public/&amp;lt;/code&amp;gt; folder&#039;&#039;&#039; — alongside the main UI (&amp;lt;code&amp;gt;index.html&amp;lt;/code&amp;gt;) you&#039;ll find:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;rate-editor.html&amp;lt;/code&amp;gt; — generates your rates post for Hive&lt;br /&gt;
* &amp;lt;code&amp;gt;server-sign.html&amp;lt;/code&amp;gt; — generates the signed federation verify file (used in [[#Step_17:_Federation_Setup_(Optional)|Federation Setup]] below)&lt;br /&gt;
* &amp;lt;code&amp;gt;server-announce.html&amp;lt;/code&amp;gt; — publishes your server to the on-chain federation directory&lt;br /&gt;
* &amp;lt;code&amp;gt;admin-peers.html&amp;lt;/code&amp;gt; — manages your federation peer list&lt;br /&gt;
* &amp;lt;code&amp;gt;info.html&amp;lt;/code&amp;gt; — public landing page for visitors who haven&#039;t logged in&lt;br /&gt;
* &amp;lt;code&amp;gt;.well-known/v4call-server.json&amp;lt;/code&amp;gt; — placeholder; each operator overwrites this with their own signed file in [[#Step_17:_Federation_Setup_(Optional)|Federation Setup]]&lt;br /&gt;
&lt;br /&gt;
== Step 8: Configure Your Server (.env file) ==&lt;br /&gt;
&lt;br /&gt;
All settings live in a single &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; file. Copy the template:&lt;br /&gt;
&lt;br /&gt;
 cp .env.example .env&lt;br /&gt;
 nano .env&lt;br /&gt;
&lt;br /&gt;
Fill in your values:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# ── Server Identity ──────────────────────────────────────&lt;br /&gt;
SERVER_NAME=yourcallapp&lt;br /&gt;
SERVER_DOMAIN=call.yourdomain.com&lt;br /&gt;
SERVER_HIVE_ACCOUNT=yourhiveaccount&lt;br /&gt;
ESCROW_ACCOUNT=yourescrowaccount&lt;br /&gt;
V4CALL_ESCROW_KEY=5Kxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&lt;br /&gt;
# Secret key to access /admin/* endpoints.&lt;br /&gt;
# Choose a long random string — treat it like a password.&lt;br /&gt;
# Example: openssl rand -hex 32&lt;br /&gt;
ADMIN_KEY=make-up-a-long-random-string-at-least-20-characters&lt;br /&gt;
&lt;br /&gt;
# ── Hive Blockchain ──────────────────────────────────────&lt;br /&gt;
CHAIN=hive&lt;br /&gt;
HIVE_API=&lt;br /&gt;
&lt;br /&gt;
# ── Platform Fee ─────────────────────────────────────────&lt;br /&gt;
# Percentage your server takes from each paid call/DM (10 = 10%)&lt;br /&gt;
# This is the MINIMUM fee — users whose rates post sets a lower&lt;br /&gt;
# platform fee will be rejected. Users who set a higher fee&lt;br /&gt;
# get the best price (your server&#039;s rate, not their higher number).&lt;br /&gt;
DEFAULT_PLATFORM_FEE=10&lt;br /&gt;
&lt;br /&gt;
# ── Network ──────────────────────────────────────────────&lt;br /&gt;
PORT=3000&lt;br /&gt;
BIND_HOST=127.0.0.1&lt;br /&gt;
&lt;br /&gt;
# ── Chat Storage ─────────────────────────────────────────&lt;br /&gt;
# How many days to keep stored DMs before automatic cleanup&lt;br /&gt;
DM_RETENTION_DAYS=33&lt;br /&gt;
# How many days to keep stored room messages before cleanup&lt;br /&gt;
ROOM_RETENTION_DAYS=33&lt;br /&gt;
# How many recent DMs per conversation to show on login (0 = off)&lt;br /&gt;
DM_PREVIEW_COUNT=1&lt;br /&gt;
&lt;br /&gt;
# ── Call Behaviour (advanced — defaults are fine) ────────&lt;br /&gt;
# CALL_COOLDOWN_MS=30000&lt;br /&gt;
# MAX_CALL_DURATION_MIN=120&lt;br /&gt;
# PAYMENT_VERIFY_RETRIES=3&lt;br /&gt;
# PAYMENT_VERIFY_DELAY_MS=5000&lt;br /&gt;
&lt;br /&gt;
# ── Federation (optional) ────────────────────────────────&lt;br /&gt;
# Comma-separated list of peer v4call server federation WebSocket URLs.&lt;br /&gt;
# Leave blank for standalone mode (no federation).&lt;br /&gt;
# Servers listed here are auto-approved on startup. Discovered peers&lt;br /&gt;
# (via Hive directory) need manual approval at /admin-peers.html.&lt;br /&gt;
# Example two-server setup:&lt;br /&gt;
#   On call.completenoobs.com → FEDERATION_PEERS=wss://hive-book.com/federation&lt;br /&gt;
#   On hive-book.com           → FEDERATION_PEERS=wss://call.completenoobs.com/federation&lt;br /&gt;
FEDERATION_PEERS=&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Key points:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;V4CALL_ESCROW_KEY&amp;lt;/code&amp;gt; — must be the &#039;&#039;&#039;active&#039;&#039;&#039; private key for your escrow account. Find it in your Hive wallet → Keys &amp;amp; Permissions → Active. Starts with &amp;lt;code&amp;gt;5K&amp;lt;/code&amp;gt;. &#039;&#039;&#039;Never share this.&#039;&#039;&#039;&lt;br /&gt;
* &amp;lt;code&amp;gt;ADMIN_KEY&amp;lt;/code&amp;gt; — invent a secret password for accessing admin tools&lt;br /&gt;
* &amp;lt;code&amp;gt;HIVE_API&amp;lt;/code&amp;gt; — leave blank to use all built-in Hive nodes automatically&lt;br /&gt;
* &amp;lt;code&amp;gt;DEFAULT_PLATFORM_FEE&amp;lt;/code&amp;gt; — your server&#039;s minimum platform fee percentage. See [[#Platform_Fee_System|Platform Fee System]] below for how this works.&lt;br /&gt;
* &amp;lt;code&amp;gt;DM_RETENTION_DAYS&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;ROOM_RETENTION_DAYS&amp;lt;/code&amp;gt; — how long chat messages are kept in the database. A cleanup job runs every hour and deletes anything older. Set to &amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; to keep messages indefinitely (not recommended).&lt;br /&gt;
* &amp;lt;code&amp;gt;DM_PREVIEW_COUNT&amp;lt;/code&amp;gt; — when a user logs in, this many recent DMs per conversation are loaded into the lobby chat so they can see previews. Set to &amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; to disable previews (users still get an unread count alert).&lt;br /&gt;
* &amp;lt;code&amp;gt;FEDERATION_PEERS&amp;lt;/code&amp;gt; — leave blank for now. You can add peers later (see [[#Step_17:_Federation_Setup_(Optional)|Federation Setup]]).&lt;br /&gt;
&lt;br /&gt;
Save: &#039;&#039;&#039;Ctrl+X&#039;&#039;&#039; → &#039;&#039;&#039;Y&#039;&#039;&#039; → &#039;&#039;&#039;Enter&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Step 9: Configure Nginx — HTTP Only First ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;This step is critical.&#039;&#039;&#039; A very common mistake is putting the HTTPS/SSL config in Nginx before getting the certificate. Nginx tries to load the certificate at startup — if the file doesn&#039;t exist yet, Nginx crashes in a restart loop. Certbot then cannot serve the challenge because Nginx is down. Result: no certificate, stuck in a loop.&lt;br /&gt;
&lt;br /&gt;
The fix: always start with HTTP only, get the certificate, then add HTTPS.&lt;br /&gt;
&lt;br /&gt;
Edit the Nginx config:&lt;br /&gt;
&lt;br /&gt;
 nano /opt/v4call/nginx/v4call.conf&lt;br /&gt;
&lt;br /&gt;
Delete everything and replace with this HTTP-only config:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
server {&lt;br /&gt;
    listen 80;&lt;br /&gt;
    server_name call.yourdomain.com www.call.yourdomain.com;&lt;br /&gt;
&lt;br /&gt;
    # Certbot challenge path — do not remove this block&lt;br /&gt;
    location /.well-known/acme-challenge/ {&lt;br /&gt;
        root /var/www/certbot;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    # Proxy all other requests to the v4call app&lt;br /&gt;
    location / {&lt;br /&gt;
        proxy_pass         http://app:3000;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header   Upgrade $http_upgrade;&lt;br /&gt;
        proxy_set_header   Connection &amp;quot;upgrade&amp;quot;;&lt;br /&gt;
        proxy_set_header   Host $host;&lt;br /&gt;
        proxy_set_header   X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header   X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_read_timeout 86400;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Replace &amp;lt;code&amp;gt;call.yourdomain.com&amp;lt;/code&amp;gt; with your actual domain in both places.&lt;br /&gt;
&lt;br /&gt;
Save: &#039;&#039;&#039;Ctrl+X&#039;&#039;&#039; → &#039;&#039;&#039;Y&#039;&#039;&#039; → &#039;&#039;&#039;Enter&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Step 10: Create Data Directories and Fix Permissions ==&lt;br /&gt;
&lt;br /&gt;
Create the folders Docker uses for persistent data:&lt;br /&gt;
&lt;br /&gt;
 mkdir -p /opt/v4call/data/logs  \&lt;br /&gt;
 mkdir -p /opt/v4call/data/certbot/conf  \&lt;br /&gt;
 mkdir -p /opt/v4call/data/certbot/www/.well-known/acme-challenge  \&lt;br /&gt;
 mkdir -p /opt/v4call/data/certbot/logs&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix permissions — do not skip this.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
The v4call app runs inside Docker as user &amp;lt;code&amp;gt;node&amp;lt;/code&amp;gt; (UID 1000). On the host, these directories are created by root, so the container cannot write to them. This causes a &amp;lt;code&amp;gt;SQLITE_CANTOPEN&amp;lt;/code&amp;gt; error that crashes the app.&lt;br /&gt;
&lt;br /&gt;
Fix the logs directory for the app:&lt;br /&gt;
&lt;br /&gt;
 chown -R 1000:1000 /opt/v4call/data/logs&lt;br /&gt;
&lt;br /&gt;
Certbot runs as root so its directories stay root-owned:&lt;br /&gt;
&lt;br /&gt;
 chown -R root:root /opt/v4call/data/certbot&lt;br /&gt;
 chmod -R 755 /opt/v4call/data/certbot&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Note:&#039;&#039;&#039; The app creates two separate SQLite databases in the logs directory:&lt;br /&gt;
* &amp;lt;code&amp;gt;v4call-ledger.db&amp;lt;/code&amp;gt; — payment records (calls, ring fees, payouts). Only the server writes to this.&lt;br /&gt;
* &amp;lt;code&amp;gt;v4call-chat.db&amp;lt;/code&amp;gt; — stored DMs and room messages. Separate from the ledger for security — if a bug in chat storage were exploited, the payment ledger remains untouched.&lt;br /&gt;
&lt;br /&gt;
Federation also persists state in this directory:&lt;br /&gt;
* &amp;lt;code&amp;gt;approved-peers.json&amp;lt;/code&amp;gt; — list of federation peer domains you&#039;ve approved (created on first approval; survives container restarts).&lt;br /&gt;
&lt;br /&gt;
== Step 11: Build and Start the Server ==&lt;br /&gt;
&lt;br /&gt;
 cd /opt/v4call&lt;br /&gt;
 docker compose up -d --build&lt;br /&gt;
&lt;br /&gt;
The first build downloads dependencies and takes 2-4 minutes. Check the status:&lt;br /&gt;
&lt;br /&gt;
 docker compose ps&lt;br /&gt;
&lt;br /&gt;
You should see:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
NAME              STATUS&lt;br /&gt;
v4call-app        Up (healthy)&lt;br /&gt;
v4call-nginx      Up&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check the app started correctly:&lt;br /&gt;
&lt;br /&gt;
 docker compose logs app&lt;br /&gt;
&lt;br /&gt;
Look for:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
[ledger] SQLite ready: /app/logs/v4call-ledger.db&lt;br /&gt;
[chat] SQLite ready: /app/logs/v4call-chat.db&lt;br /&gt;
v4call server running on 0.0.0.0:3000&lt;br /&gt;
[config] Server: yourcallapp (call.yourdomain.com)&lt;br /&gt;
[config] DM retention: 33 days | Room retention: 33 days | DM preview: 1&lt;br /&gt;
✓ Escrow key verified — matches @yourescrowaccount active key&lt;br /&gt;
✓ Escrow account @yourescrowaccount balance: 0.000 HBD&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If you see &amp;lt;code&amp;gt;SqliteError: unable to open database file&amp;lt;/code&amp;gt; — run the chown command from Step 10 again then &amp;lt;code&amp;gt;docker compose restart app&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Test HTTP is working:&lt;br /&gt;
&lt;br /&gt;
 curl http://call.yourdomain.com/debug-state&lt;br /&gt;
&lt;br /&gt;
Should return: &amp;lt;code&amp;gt;{&amp;quot;lobbyUsers&amp;quot;:[],&amp;quot;rooms&amp;quot;:[]}&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If you can see the site in a browser over HTTP at this point — everything is working and ready for the certificate.&lt;br /&gt;
&lt;br /&gt;
== Step 12: Get Your SSL Certificate ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Before running certbot&#039;&#039;&#039;, verify DNS and the challenge path are both working:&lt;br /&gt;
&lt;br /&gt;
 # Check DNS points to this server&lt;br /&gt;
 nslookup call.yourdomain.com&lt;br /&gt;
&lt;br /&gt;
 # Test the challenge path&lt;br /&gt;
 echo &amp;quot;test&amp;quot; &amp;gt; /opt/v4call/data/certbot/www/.well-known/acme-challenge/testfile&lt;br /&gt;
 curl http://call.yourdomain.com/.well-known/acme-challenge/testfile&lt;br /&gt;
&lt;br /&gt;
The curl command must return &amp;lt;code&amp;gt;test&amp;lt;/code&amp;gt;. If it does not, Nginx is not running — check &amp;lt;code&amp;gt;docker compose ps&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;docker compose logs nginx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
When both work, get the certificate. The &amp;lt;code&amp;gt;--entrypoint certbot&amp;lt;/code&amp;gt; flag is required — without it, Docker runs the container&#039;s default renewal loop instead of the certonly command:&lt;br /&gt;
&lt;br /&gt;
 docker compose run --rm \&lt;br /&gt;
   --entrypoint certbot \&lt;br /&gt;
   certbot certonly \&lt;br /&gt;
   --webroot \&lt;br /&gt;
   -w /var/www/certbot \&lt;br /&gt;
   -d call.yourdomain.com \&lt;br /&gt;
   --email your@email.com \&lt;br /&gt;
   --agree-tos \&lt;br /&gt;
   --no-eff-email&lt;br /&gt;
&lt;br /&gt;
Replace &amp;lt;code&amp;gt;call.yourdomain.com&amp;lt;/code&amp;gt; with your domain and &amp;lt;code&amp;gt;your@email.com&amp;lt;/code&amp;gt; with your email.&lt;br /&gt;
&lt;br /&gt;
Success looks like:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
Requesting a certificate for call.yourdomain.com&lt;br /&gt;
&lt;br /&gt;
Successfully received certificate.&lt;br /&gt;
Certificate is saved at: /etc/letsencrypt/live/call.yourdomain.com/fullchain.pem&lt;br /&gt;
Key is saved at:         /etc/letsencrypt/live/call.yourdomain.com/privkey.pem&lt;br /&gt;
This certificate expires on 2026-07-09.&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Verify the files exist on the host:&lt;br /&gt;
&lt;br /&gt;
 ls /opt/v4call/data/certbot/conf/live/call.yourdomain.com/&lt;br /&gt;
&lt;br /&gt;
Should show: &amp;lt;code&amp;gt;cert.pem  chain.pem  fullchain.pem  privkey.pem  README&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 13: Enable HTTPS in Nginx ==&lt;br /&gt;
&lt;br /&gt;
Now the certificate exists, update Nginx to serve HTTPS.&lt;br /&gt;
&lt;br /&gt;
 nano /opt/v4call/nginx/v4call.conf&lt;br /&gt;
&lt;br /&gt;
Replace everything with the full HTTPS config:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# HTTP — only used for certbot challenge and redirect to HTTPS&lt;br /&gt;
server {&lt;br /&gt;
    listen 80;&lt;br /&gt;
    server_name call.yourdomain.com www.call.yourdomain.com;&lt;br /&gt;
&lt;br /&gt;
    location /.well-known/acme-challenge/ {&lt;br /&gt;
        root /var/www/certbot;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    location / {&lt;br /&gt;
        return 301 https://$host$request_uri;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# HTTPS — main app&lt;br /&gt;
server {&lt;br /&gt;
    listen 443 ssl;&lt;br /&gt;
    server_name call.yourdomain.com www.call.yourdomain.com;&lt;br /&gt;
&lt;br /&gt;
    # CHANGE PATH: ssl certs path uses your domain — find with: ls data/certbot/conf/live/&lt;br /&gt;
    ssl_certificate     /etc/letsencrypt/live/call.yourdomain.com/fullchain.pem;&lt;br /&gt;
    ssl_certificate_key /etc/letsencrypt/live/call.yourdomain.com/privkey.pem;&lt;br /&gt;
&lt;br /&gt;
    ssl_protocols TLSv1.2 TLSv1.3;&lt;br /&gt;
    ssl_prefer_server_ciphers off;&lt;br /&gt;
&lt;br /&gt;
    add_header Strict-Transport-Security &amp;quot;max-age=63072000&amp;quot; always;&lt;br /&gt;
    add_header X-Frame-Options DENY;&lt;br /&gt;
    add_header X-Content-Type-Options nosniff;&lt;br /&gt;
&lt;br /&gt;
    # When user cancels the basic-auth login prompt, send them here&lt;br /&gt;
    error_page 401 /info.html;&lt;br /&gt;
&lt;br /&gt;
    # info.html is served directly by Nginx — no auth, no proxy&lt;br /&gt;
    location = /info.html {&lt;br /&gt;
        root /usr/share/nginx/html;&lt;br /&gt;
        auth_basic off;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    # WebSocket — main client traffic&lt;br /&gt;
    location /socket.io/ {&lt;br /&gt;
        # Uncomment below to require username/password to enter site (testing only).&lt;br /&gt;
        # See &amp;quot;Optional: Password Protect Your Server During Testing&amp;quot; section for setup.&lt;br /&gt;
        #auth_basic           &amp;quot;v4call — Private Testing&amp;quot;;&lt;br /&gt;
        #auth_basic_user_file /etc/nginx/.htpasswd;&lt;br /&gt;
&lt;br /&gt;
        proxy_pass         http://app:3000;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header   Upgrade $http_upgrade;&lt;br /&gt;
        proxy_set_header   Connection &amp;quot;upgrade&amp;quot;;&lt;br /&gt;
        proxy_set_header   Host $host;&lt;br /&gt;
        proxy_set_header   X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header   X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_cache_bypass $http_upgrade;&lt;br /&gt;
        proxy_read_timeout 86400;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    # Federation WebSocket — server-to-server, NEVER auth-protected.&lt;br /&gt;
    # Peer v4call servers connect here to exchange presence/DMs/call invites.&lt;br /&gt;
    # Trust validation happens inside the Node app (signed verify.json check&lt;br /&gt;
    # + operator approval via /admin-peers.html). Do not add basic auth here —&lt;br /&gt;
    # it would block all federation.&lt;br /&gt;
    location /federation {&lt;br /&gt;
        proxy_pass         http://app:3000;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header   Upgrade $http_upgrade;&lt;br /&gt;
        proxy_set_header   Connection &amp;quot;upgrade&amp;quot;;&lt;br /&gt;
        proxy_set_header   Host $host;&lt;br /&gt;
        proxy_set_header   X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header   X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_read_timeout 86400;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    # Everything else — main HTTP traffic&lt;br /&gt;
    location / {&lt;br /&gt;
        # Uncomment below to require username/password to enter site (testing only).&lt;br /&gt;
        #auth_basic           &amp;quot;v4call — Private Testing&amp;quot;;&lt;br /&gt;
        #auth_basic_user_file /etc/nginx/.htpasswd;&lt;br /&gt;
&lt;br /&gt;
        proxy_pass         http://app:3000;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header   Host $host;&lt;br /&gt;
        proxy_set_header   X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header   X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_read_timeout 300;&lt;br /&gt;
        proxy_send_timeout 300;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    # Logs failed and successful login attempts when .htpasswd is enabled&lt;br /&gt;
    error_log /var/log/nginx/error.log warn;&lt;br /&gt;
    access_log /var/log/nginx/access.log;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* REPLACE &amp;lt;code&amp;gt;call.yourdomain.com&amp;lt;/code&amp;gt; with your domain throughout (it appears 4 times — server_name lines and ssl_certificate paths).&lt;br /&gt;
* The &amp;lt;code&amp;gt;auth_basic&amp;lt;/code&amp;gt; lines are &#039;&#039;&#039;commented out by default&#039;&#039;&#039; so federation and normal users work out-of-box. To enable password protection for private testing, see [[#Optional:_Password_Protect_Your_Server_During_Testing|the Optional section]] below — it shows how to uncomment them and create users.&lt;br /&gt;
* &#039;&#039;&#039;Never&#039;&#039;&#039; add &amp;lt;code&amp;gt;auth_basic&amp;lt;/code&amp;gt; to the &amp;lt;code&amp;gt;/federation&amp;lt;/code&amp;gt; block — it would block all peer v4call servers from connecting to you.&lt;br /&gt;
&lt;br /&gt;
Save and restart Nginx:&lt;br /&gt;
&lt;br /&gt;
 docker compose restart nginx&lt;br /&gt;
&lt;br /&gt;
Check the logs — you should see &#039;&#039;&#039;no&#039;&#039;&#039; &amp;lt;code&amp;gt;[emerg]&amp;lt;/code&amp;gt; errors:&lt;br /&gt;
&lt;br /&gt;
 docker compose logs nginx&lt;br /&gt;
&lt;br /&gt;
The last lines should show:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
nginx/1.x.x ...&lt;br /&gt;
start worker processes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* If you see an error such as:&lt;br /&gt;
&amp;lt;pre&amp;gt;[emerg] 1#1: cannot load certificate &amp;quot;/etc/letsencrypt/live/call.yourdomain.com/fullchain.pem&amp;quot;: BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/etc/letsencrypt/live/call.yourdomain.com/fullchain.pem, r) error:10000080:BIO routines::no such file)&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Make sure you edited &amp;lt;code&amp;gt;nginx/v4call.conf&amp;lt;/code&amp;gt; correctly.&lt;br /&gt;
* To find the path for your SSL certs look in path &amp;lt;code&amp;gt;data/certbot/conf/live/&amp;lt;/code&amp;gt;&lt;br /&gt;
* There are 4 lines to check in &amp;lt;code&amp;gt;nginx/v4call.conf&amp;lt;/code&amp;gt;:&lt;br /&gt;
** &amp;lt;code&amp;gt;server_name call.yourdomain.com www.call.yourdomain.com;&amp;lt;/code&amp;gt; (HTTP block)&lt;br /&gt;
** &amp;lt;code&amp;gt;server_name call.yourdomain.com www.call.yourdomain.com;&amp;lt;/code&amp;gt; (HTTPS block)&lt;br /&gt;
** &amp;lt;code&amp;gt;ssl_certificate     /etc/letsencrypt/live/call.yourdomain.com/fullchain.pem;&amp;lt;/code&amp;gt;&lt;br /&gt;
** &amp;lt;code&amp;gt;ssl_certificate_key /etc/letsencrypt/live/call.yourdomain.com/privkey.pem;&amp;lt;/code&amp;gt;&lt;br /&gt;
* When done restart nginx with: &amp;lt;code&amp;gt;docker compose restart nginx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 14: Set Up SSL Auto-Renewal ==&lt;br /&gt;
&lt;br /&gt;
Let&#039;s Encrypt certificates expire after 90 days. A cron job renews them automatically.&lt;br /&gt;
&lt;br /&gt;
 crontab -e&lt;br /&gt;
&lt;br /&gt;
Add this line at the bottom:&lt;br /&gt;
&lt;br /&gt;
 0 3 * * * cd /opt/v4call &amp;amp;&amp;amp; docker compose run --rm --entrypoint certbot certbot renew --quiet &amp;amp;&amp;amp; docker compose exec nginx nginx -s reload&lt;br /&gt;
&lt;br /&gt;
Save and exit. This runs at 3am every day, renews if the cert is close to expiry, and reloads Nginx to pick up the new certificate.&lt;br /&gt;
&lt;br /&gt;
== Step 15: Test Everything is Working ==&lt;br /&gt;
&lt;br /&gt;
Test HTTPS:&lt;br /&gt;
&lt;br /&gt;
 curl https://call.yourdomain.com/debug-state&lt;br /&gt;
&lt;br /&gt;
Should return: &amp;lt;code&amp;gt;{&amp;quot;lobbyUsers&amp;quot;:[],&amp;quot;rooms&amp;quot;:[]}&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Test admin access:&lt;br /&gt;
&lt;br /&gt;
 curl &amp;quot;https://call.yourdomain.com/admin/balance?key=YOUR_ADMIN_KEY&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Should return your escrow account balance.&lt;br /&gt;
&lt;br /&gt;
Open your browser and go to &amp;lt;code&amp;gt;https://call.yourdomain.com&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You should see the v4call login page with:&lt;br /&gt;
* A padlock icon in the browser address bar&lt;br /&gt;
* A green &#039;&#039;&#039;⚡ Sign in with Keychain&#039;&#039;&#039; button (if Hive Keychain is installed)&lt;br /&gt;
* A manual posting key login option below it&lt;br /&gt;
* A &#039;&#039;&#039;📖 New here? Learn the v4call basics →&#039;&#039;&#039; link at the bottom&lt;br /&gt;
&lt;br /&gt;
Log in with Hive Keychain or a posting key to confirm the login flow works. 🎉&lt;br /&gt;
&lt;br /&gt;
== Step 16: Set Up Your Call Rates on Hive ==&lt;br /&gt;
&lt;br /&gt;
For callers to be charged when they ring you, publish your rates on the Hive blockchain:&lt;br /&gt;
&lt;br /&gt;
# Make sure Hive Keychain is installed in your browser&lt;br /&gt;
# Go to &amp;lt;code&amp;gt;https://call.yourdomain.com/rate-editor.html&amp;lt;/code&amp;gt;&lt;br /&gt;
# Enter your Hive username&lt;br /&gt;
# Set your rates — ring fee, connect fee, duration rate per hour, minimum credit deposit&lt;br /&gt;
# Set &amp;lt;code&amp;gt;PLATFORM-FEE&amp;lt;/code&amp;gt; to at least your server&#039;s &amp;lt;code&amp;gt;DEFAULT_PLATFORM_FEE&amp;lt;/code&amp;gt; percentage (e.g. &amp;lt;code&amp;gt;10&amp;lt;/code&amp;gt; for 10%). If you set it lower, paid contacts to your account will be rejected on this server.&lt;br /&gt;
# Optionally add custom token sections (e.g. &amp;lt;code&amp;gt;[TOKEN:CNOOBS]&amp;lt;/code&amp;gt;) to offer discounted rates for callers who hold your token&lt;br /&gt;
# Click &#039;&#039;&#039;Generate&#039;&#039;&#039; to preview the rates block&lt;br /&gt;
# Click &#039;&#039;&#039;Post to Hive&#039;&#039;&#039; — Keychain will ask you to approve the post&lt;br /&gt;
&lt;br /&gt;
This creates a post titled &amp;lt;code&amp;gt;v4call-rates&amp;lt;/code&amp;gt; on your Hive blog. Your server reads this post automatically.&lt;br /&gt;
&lt;br /&gt;
To verify your server read the rates correctly:&lt;br /&gt;
&lt;br /&gt;
 https://call.yourdomain.com/debug-rates/yourusername&lt;br /&gt;
&lt;br /&gt;
To test with a specific caller (checks their token balances too):&lt;br /&gt;
&lt;br /&gt;
 https://call.yourdomain.com/debug-rates/yourusername?caller=theirusername&amp;amp;type=voice&lt;br /&gt;
&lt;br /&gt;
You should see your rates as JSON, including which currency and rates apply for that caller.&lt;br /&gt;
&lt;br /&gt;
== Step 17: Federation Setup (Optional) ==&lt;br /&gt;
&lt;br /&gt;
If you want your server to talk to other v4call servers — so users on different servers can call/DM each other — set up federation. This is optional. A standalone server works fine without it.&lt;br /&gt;
&lt;br /&gt;
Federation has two trust steps:&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Domain proof&#039;&#039;&#039; — you publish a signed JSON file at &amp;lt;code&amp;gt;https://yourdomain.com/.well-known/v4call-server.json&amp;lt;/code&amp;gt; proving that your Hive account controls this domain&lt;br /&gt;
# &#039;&#039;&#039;Directory listing&#039;&#039;&#039; — you publish a Hive post tagged &amp;lt;code&amp;gt;v4call-server&amp;lt;/code&amp;gt; announcing your server exists, so other operators can discover you&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important escrow rule&#039;&#039;&#039;: the &amp;lt;code&amp;gt;ESCROW_ACCOUNT&amp;lt;/code&amp;gt; in your &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; must match the &amp;lt;code&amp;gt;ESCROW:&amp;lt;/code&amp;gt; field in your users&#039; rates posts. The active key for that escrow has to live on &#039;&#039;&#039;this&#039;&#039;&#039; server. If a user&#039;s rates post points at a different escrow than what your server holds the key for, paid calls/DMs to that user will fail (your server can&#039;t disburse from an escrow it doesn&#039;t control).&lt;br /&gt;
&lt;br /&gt;
=== Step 17a: Generate and Host the Verify File ===&lt;br /&gt;
&lt;br /&gt;
# Open &amp;lt;code&amp;gt;https://yourdomain.com/server-sign.html&amp;lt;/code&amp;gt; in a browser with Hive Keychain installed&lt;br /&gt;
# Fill in: Hive account (same as &amp;lt;code&amp;gt;SERVER_HIVE_ACCOUNT&amp;lt;/code&amp;gt;), domain, escrow account, fee account, federation WS URL (&amp;lt;code&amp;gt;wss://yourdomain.com/federation&amp;lt;/code&amp;gt;)&lt;br /&gt;
# Click &#039;&#039;&#039;⚡ Sign with Hive Keychain&#039;&#039;&#039;&lt;br /&gt;
# Click &#039;&#039;&#039;⬇ Download&#039;&#039;&#039; — saves &amp;lt;code&amp;gt;v4call-server.json&amp;lt;/code&amp;gt;&lt;br /&gt;
# Upload to your server, replacing the placeholder file. From your local machine:&lt;br /&gt;
   scp v4call-server.json root@yourvps:/opt/v4call/public/.well-known/&lt;br /&gt;
&lt;br /&gt;
(Or use any other transfer method you prefer — SFTP client, paste via SSH, etc.)&lt;br /&gt;
&lt;br /&gt;
Verify it&#039;s served:&lt;br /&gt;
 curl https://yourdomain.com/.well-known/v4call-server.json&lt;br /&gt;
&lt;br /&gt;
You should see your signed JSON. If you see &amp;lt;code&amp;gt;404&amp;lt;/code&amp;gt;, check the file is at exactly &amp;lt;code&amp;gt;public/.well-known/v4call-server.json&amp;lt;/code&amp;gt; — the directory uses a &#039;&#039;&#039;hyphen&#039;&#039;&#039;, not an underscore (RFC 8615), and the filename must be exactly &amp;lt;code&amp;gt;v4call-server.json&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Rebuild so the file is baked into the Docker image:&lt;br /&gt;
 docker compose down &amp;amp;&amp;amp; docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Tip&#039;&#039;&#039;: commit the generated file to your git repo so future fresh installs include it automatically. It contains only public data — safe to commit. The placeholder shipped in the repo is overwritten by your file.&lt;br /&gt;
&lt;br /&gt;
=== Step 17b: Announce on Hive ===&lt;br /&gt;
&lt;br /&gt;
For other operators to discover you, publish a Hive post advertising your server:&lt;br /&gt;
&lt;br /&gt;
# After signing in Step 17a, click the &#039;&#039;&#039;📡 Announce on Hive →&#039;&#039;&#039; link on the signer output card (auto-fills the announce page)&lt;br /&gt;
# Or open &amp;lt;code&amp;gt;https://yourdomain.com/server-announce.html&amp;lt;/code&amp;gt; directly&lt;br /&gt;
# Confirm the prefilled values&lt;br /&gt;
# Click &#039;&#039;&#039;📡 Post to Hive&#039;&#039;&#039; — Keychain will broadcast the post&lt;br /&gt;
&lt;br /&gt;
The post lives forever on Hive. If your config changes, just post a new one — the most recent post per Hive account wins. No need to delete old posts (Hive posts are effectively permanent anyway).&lt;br /&gt;
&lt;br /&gt;
=== Step 17c: Connect to Known Peers (Manual Mode) ===&lt;br /&gt;
&lt;br /&gt;
Skip the discovery step and go straight to a known peer by adding their WebSocket URL to &amp;lt;code&amp;gt;FEDERATION_PEERS&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;:&lt;br /&gt;
 FEDERATION_PEERS=wss://otherserver.com/federation&lt;br /&gt;
&lt;br /&gt;
Both servers must list each other. Rebuild after editing &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;:&lt;br /&gt;
 docker compose down &amp;amp;&amp;amp; docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&lt;br /&gt;
Servers in &amp;lt;code&amp;gt;FEDERATION_PEERS&amp;lt;/code&amp;gt; are auto-approved on startup — they bypass the manual approval step.&lt;br /&gt;
&lt;br /&gt;
=== Step 17d: Discover and Approve Peers (Auto Mode) ===&lt;br /&gt;
&lt;br /&gt;
Your server scans Hive for &amp;lt;code&amp;gt;v4call-server&amp;lt;/code&amp;gt; posts on startup and every 2 hours. Discovered peers are NOT auto-connected — you must approve each one.&lt;br /&gt;
&lt;br /&gt;
# Open &amp;lt;code&amp;gt;https://yourdomain.com/admin-peers.html&amp;lt;/code&amp;gt;&lt;br /&gt;
# Paste your &amp;lt;code&amp;gt;ADMIN_KEY&amp;lt;/code&amp;gt; (from &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;) and click &#039;&#039;&#039;🔄 Load / Refresh&#039;&#039;&#039;&lt;br /&gt;
# Each discovered peer shows a card with verification status, escrow, fee account, post age&lt;br /&gt;
# Click &#039;&#039;&#039;✓ Approve&#039;&#039;&#039; to add them to your federation&lt;br /&gt;
# Click &#039;&#039;&#039;✗ Revoke&#039;&#039;&#039; to remove an approved peer&lt;br /&gt;
&lt;br /&gt;
Approvals persist to &amp;lt;code&amp;gt;/app/logs/approved-peers.json&amp;lt;/code&amp;gt; so they survive container restarts.&lt;br /&gt;
&lt;br /&gt;
=== Federation Health Check ===&lt;br /&gt;
&lt;br /&gt;
Watch the federation logs:&lt;br /&gt;
 docker compose logs -f app | grep -E &amp;quot;\[federation\]|\[discovery\]|\[peers\]&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Healthy startup looks like:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
[federation] Approved peers: peer-domain.com&lt;br /&gt;
[federation] Connecting to wss://peer-domain.com/federation...&lt;br /&gt;
[federation] Outbound connected: wss://peer-domain.com/federation&lt;br /&gt;
[federation] ✓ Peer verified: @peer-domain.com (signer: @peer-account, escrow: @peer-escrow)&lt;br /&gt;
[discovery] Hive returned 2 post(s) under v4call-server tag&lt;br /&gt;
[discovery] Scan complete — 2 v4call-server post(s), 2 verified&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When a user logs in on either server, you should see:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
[federation] → user-online @username → 1 peer(s)         (on their home server)&lt;br /&gt;
[federation] ← user-online @username@theirhome.com       (on your server)&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Feature Guide: What Your Server Can Do ==&lt;br /&gt;
&lt;br /&gt;
This section explains all the features available in v4call and how they work. No code changes needed — everything described here is built in and ready to use.&lt;br /&gt;
&lt;br /&gt;
=== Login Options ===&lt;br /&gt;
&lt;br /&gt;
v4call supports two ways to sign in:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Hive Keychain&#039;&#039;&#039; (recommended) — click the green &#039;&#039;&#039;⚡ Sign in with Keychain&#039;&#039;&#039; button. Keychain signs a challenge to prove your identity. No key paste needed. After login, a 🔑 panel appears in the lobby where you can optionally enter your posting key to unlock encrypted messaging (Keychain cannot expose private keys, so encryption needs the key entered once per session).&lt;br /&gt;
* &#039;&#039;&#039;Manual posting key&#039;&#039;&#039; — paste your Hive posting private key (starts with &amp;lt;code&amp;gt;5J&amp;lt;/code&amp;gt;) directly. The key stays in browser session memory only — never sent to the server.&lt;br /&gt;
&lt;br /&gt;
Both methods verify your identity against the Hive blockchain.&lt;br /&gt;
&lt;br /&gt;
=== Voice and Video Calls ===&lt;br /&gt;
&lt;br /&gt;
Each online user in the lobby shows three action buttons:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;📞 Green phone icon&#039;&#039;&#039; — start a &#039;&#039;&#039;voice-only&#039;&#039;&#039; call (audio, no camera)&lt;br /&gt;
* &#039;&#039;&#039;🎥 Blue camera icon&#039;&#039;&#039; — start a &#039;&#039;&#039;video&#039;&#039;&#039; call (audio + camera)&lt;br /&gt;
* &#039;&#039;&#039;💬 Purple chat bubble icon&#039;&#039;&#039; — open the &#039;&#039;&#039;DM panel&#039;&#039;&#039; to send a direct message&lt;br /&gt;
&lt;br /&gt;
Voice calls request microphone only — no camera permission prompt. Video calls request both. The caller and callee can have different call types — the type is set by whoever initiates the call.&lt;br /&gt;
&lt;br /&gt;
Separate rates can be set for voice and video calls in the rates post (voice is typically cheaper).&lt;br /&gt;
&lt;br /&gt;
Federated users (on a different v4call server) appear in the lobby with a small server-domain badge under their username. The call/DM buttons work the same — federation routing is transparent.&lt;br /&gt;
&lt;br /&gt;
=== Direct Messages (DMs) ===&lt;br /&gt;
&lt;br /&gt;
Click the purple 💬 button next to any online user to open the DM panel. DMs are end-to-end encrypted using Hive posting keys — the server stores only ciphertext it cannot read.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Chat storage:&#039;&#039;&#039; DMs are stored on the server in an encrypted database (&amp;lt;code&amp;gt;v4call-chat.db&amp;lt;/code&amp;gt;) for up to &amp;lt;code&amp;gt;DM_RETENTION_DAYS&amp;lt;/code&amp;gt; (default: 33 days). Both sender and recipient get their own encrypted copy stored, so both can retrieve their history later.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Unread alerts:&#039;&#039;&#039; When you log in, if you have unread DMs, a popup appears showing how many messages from how many users. Click a username in the popup to open their DM history.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;DM previews:&#039;&#039;&#039; The last &amp;lt;code&amp;gt;DM_PREVIEW_COUNT&amp;lt;/code&amp;gt; messages per conversation are loaded into the lobby chat on login, so you can see recent activity at a glance. Set to &amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; to disable.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Full history:&#039;&#039;&#039; Click the DM button for any user to load the complete conversation history, shown between &amp;quot;— DM history —&amp;quot; dividers.&lt;br /&gt;
&lt;br /&gt;
=== Rooms ===&lt;br /&gt;
&lt;br /&gt;
Users can create private rooms by selecting users in the lobby (toggle switch) and clicking &#039;&#039;&#039;Create &amp;amp; Invite&#039;&#039;&#039;. Rooms support encrypted messaging, video, and voice.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Room history:&#039;&#039;&#039; When a new user joins a room, they see past messages — broadcasts in full, encrypted messages only if they were addressed to them. A &amp;quot;— earlier messages —&amp;quot; divider separates history from live messages.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Ephemeral rooms:&#039;&#039;&#039; A warning banner at the top of every room says: &amp;quot;⚠ Room is ephemeral — if all users leave, the room and its history are deleted. New members can only read messages encrypted to their key.&amp;quot; When the last person leaves a room, all stored messages for that room are deleted from the database.&lt;br /&gt;
&lt;br /&gt;
=== Custom Token Payments (Hive-Engine) ===&lt;br /&gt;
&lt;br /&gt;
v4call supports payment in any Hive-Engine token, not just HBD. This is configured per-user in their rates post using &amp;lt;code&amp;gt;[TOKEN:SYMBOL]&amp;lt;/code&amp;gt; sections.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;How it works:&#039;&#039;&#039;&lt;br /&gt;
# A user (e.g. @cnoobz) creates a custom token on Hive-Engine (e.g. CNOOBS)&lt;br /&gt;
# In their rates post, they add a &amp;lt;code&amp;gt;[TOKEN:CNOOBS]&amp;lt;/code&amp;gt; section with lower rates than their default HBD rates&lt;br /&gt;
# When a caller who holds CNOOBS contacts @cnoobz, the server detects the token balance and offers the token rates&lt;br /&gt;
# If the caller holds multiple qualifying tokens, &#039;&#039;&#039;all options are shown&#039;&#039;&#039; in a currency picker — the caller chooses which to pay with&lt;br /&gt;
# The payment goes through Hive Keychain as a &amp;lt;code&amp;gt;custom_json&amp;lt;/code&amp;gt; Hive-Engine transfer&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;For the escrow account:&#039;&#039;&#039; Your escrow account needs to hold some of each custom token that users on your server accept. Token payouts (to the callee) and refunds (to the caller) are sent from the escrow account&#039;s token balance, just like HBD.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Payment picker:&#039;&#039;&#039; When multiple payment options exist (e.g. CNOOBS at 1 per message, HBD at 100000 per message), the payment modal shows clickable currency buttons so the caller can see all rates and choose the best option.&lt;br /&gt;
&lt;br /&gt;
=== Platform Fee System ===&lt;br /&gt;
&lt;br /&gt;
The platform fee is how your server earns revenue from paid calls and DMs.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;How it works:&#039;&#039;&#039;&lt;br /&gt;
* Your server&#039;s &amp;lt;code&amp;gt;DEFAULT_PLATFORM_FEE&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; is the &#039;&#039;&#039;minimum&#039;&#039;&#039; percentage your server accepts (e.g. &amp;lt;code&amp;gt;10&amp;lt;/code&amp;gt; = 10%)&lt;br /&gt;
* Each user sets &amp;lt;code&amp;gt;PLATFORM-FEE&amp;lt;/code&amp;gt; in their Hive rates post — this is the maximum fee they are willing to pay to a server&lt;br /&gt;
* If the user&#039;s posted fee is &#039;&#039;&#039;lower&#039;&#039;&#039; than your server&#039;s minimum → &#039;&#039;&#039;rejected&#039;&#039;&#039;. The caller sees a message explaining the mismatch, and the callee is told to raise their fee.&lt;br /&gt;
* If the user&#039;s posted fee &#039;&#039;&#039;meets or exceeds&#039;&#039;&#039; your server&#039;s minimum → &#039;&#039;&#039;accepted&#039;&#039;&#039;, and &#039;&#039;&#039;the server charges its own rate&#039;&#039;&#039; (the minimum), not the user&#039;s higher number. The callee gets the best price.&lt;br /&gt;
* If the user has &#039;&#039;&#039;no &amp;lt;code&amp;gt;PLATFORM-FEE&amp;lt;/code&amp;gt; line&#039;&#039;&#039; in their rates post → the server&#039;s default is used automatically. No mismatch.&lt;br /&gt;
* &#039;&#039;&#039;Free contacts&#039;&#039;&#039; (no payment involved) are never affected by fee enforcement.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why this matters for federation:&#039;&#039;&#039; Different servers can set different platform fees. Users can shop around — pick a server with a fee they find agreeable. This creates a free market for server operators.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Example:&#039;&#039;&#039;&lt;br /&gt;
* Your server: &amp;lt;code&amp;gt;DEFAULT_PLATFORM_FEE=3&amp;lt;/code&amp;gt; (3%)&lt;br /&gt;
* @alice posts: &amp;lt;code&amp;gt;PLATFORM-FEE: 5%&amp;lt;/code&amp;gt; → accepted, server charges 3% (best price for alice)&lt;br /&gt;
* @bob posts: &amp;lt;code&amp;gt;PLATFORM-FEE: 1%&amp;lt;/code&amp;gt; → rejected, bob needs to raise to at least 3%&lt;br /&gt;
* @charlie has no fee line → defaults to 3%, automatically accepted&lt;br /&gt;
&lt;br /&gt;
=== Federation ===&lt;br /&gt;
&lt;br /&gt;
Federation lets users on different v4call servers see, call, and DM each other. Each server independently:&lt;br /&gt;
&lt;br /&gt;
* Holds its own escrow account (the active key never leaves the server it belongs to)&lt;br /&gt;
* Verifies and disburses payments through its own escrow&lt;br /&gt;
* Takes the platform fee for calls/DMs received by its own users&lt;br /&gt;
&lt;br /&gt;
Cross-server payments work like this: caller pays the callee&#039;s escrow on Hive (read from the callee&#039;s rates post), the callee&#039;s server verifies the payment on-chain and forwards a notification, both servers exchange call/DM signalling, and the callee&#039;s server disburses callee-net + platform-fee from its own escrow at call end (with refunds going cross-server back to the caller as a normal Hive transfer).&lt;br /&gt;
&lt;br /&gt;
WebRTC media stays peer-to-peer — neither server sees the audio/video.&lt;br /&gt;
&lt;br /&gt;
See [[#Step_17:_Federation_Setup_(Optional)|Step 17: Federation Setup]] for the setup steps.&lt;br /&gt;
&lt;br /&gt;
== Admin Configuration Reference ==&lt;br /&gt;
&lt;br /&gt;
All settings are in the &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; file. After changing any value, rebuild:&lt;br /&gt;
&lt;br /&gt;
 docker compose down &amp;amp;&amp;amp; docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Variable !! Default !! Description&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;SERVER_NAME&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;v4call&amp;lt;/code&amp;gt; || Display name for your server&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;SERVER_DOMAIN&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;v4call.com&amp;lt;/code&amp;gt; || Your server&#039;s domain&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;SERVER_HIVE_ACCOUNT&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;v4call&amp;lt;/code&amp;gt; || Hive account that receives platform fees (and signs your federation verify file)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ESCROW_ACCOUNT&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;v4call-escrow&amp;lt;/code&amp;gt; || Hive account that holds funds during calls. The active key for this account must live on &#039;&#039;&#039;this&#039;&#039;&#039; server.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;V4CALL_ESCROW_KEY&amp;lt;/code&amp;gt; || &#039;&#039;(none)&#039;&#039; || Active private key for the escrow account. &#039;&#039;&#039;Required.&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ADMIN_KEY&amp;lt;/code&amp;gt; || &#039;&#039;(none)&#039;&#039; || Password for admin endpoints (&amp;lt;code&amp;gt;/admin/balance&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;/admin/ledger&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;/admin/peers&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;DEFAULT_PLATFORM_FEE&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;10&amp;lt;/code&amp;gt; || Server&#039;s minimum platform fee percentage&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;FEDERATION_PEERS&amp;lt;/code&amp;gt; || &#039;&#039;(blank)&#039;&#039; || Comma-separated peer WebSocket URLs (e.g. &amp;lt;code&amp;gt;wss://peer.com/federation&amp;lt;/code&amp;gt;). Listed peers are auto-approved on startup. Blank = standalone mode.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;DM_RETENTION_DAYS&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;33&amp;lt;/code&amp;gt; || Days to keep stored DMs before cleanup&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ROOM_RETENTION_DAYS&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;33&amp;lt;/code&amp;gt; || Days to keep stored room messages before cleanup&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;DM_PREVIEW_COUNT&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;1&amp;lt;/code&amp;gt; || Recent DMs per conversation shown on login (0 = off)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;HIVE_API&amp;lt;/code&amp;gt; || &#039;&#039;(blank)&#039;&#039; || Override primary Hive API node. Blank = auto-select from built-in list&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;MAX_CALL_DURATION_MIN&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;120&amp;lt;/code&amp;gt; || Maximum call length in minutes before auto-disconnect&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CALL_COOLDOWN_MS&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;30000&amp;lt;/code&amp;gt; || Milliseconds between call attempts to same user&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;PAYMENT_VERIFY_RETRIES&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;3&amp;lt;/code&amp;gt; || Number of attempts to verify a blockchain payment&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;PAYMENT_VERIFY_DELAY_MS&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;5000&amp;lt;/code&amp;gt; || Delay between verification retry attempts&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Debug &amp;amp; Admin Endpoints ===&lt;br /&gt;
&lt;br /&gt;
These are useful for testing without making actual calls:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;/debug-state&amp;lt;/code&amp;gt; — shows current lobby users and active rooms (no auth required)&lt;br /&gt;
* &amp;lt;code&amp;gt;/debug-rates/USERNAME&amp;lt;/code&amp;gt; — shows parsed rates for a user&lt;br /&gt;
* &amp;lt;code&amp;gt;/debug-rates/USERNAME?caller=CALLER&amp;amp;type=voice&amp;lt;/code&amp;gt; — shows what rates a specific caller would receive (checks token balances too)&lt;br /&gt;
* &amp;lt;code&amp;gt;/admin/balance?key=YOUR_ADMIN_KEY&amp;lt;/code&amp;gt; — shows escrow account HBD balance&lt;br /&gt;
* &amp;lt;code&amp;gt;/admin/ledger?key=YOUR_ADMIN_KEY&amp;lt;/code&amp;gt; — shows recent payment records&lt;br /&gt;
* &amp;lt;code&amp;gt;/admin/peers?key=YOUR_ADMIN_KEY&amp;lt;/code&amp;gt; — JSON list of discovered + approved federation peers&lt;br /&gt;
* &amp;lt;code&amp;gt;/admin/peers/approve?key=YOUR_ADMIN_KEY&amp;amp;domain=peer.com&amp;lt;/code&amp;gt; (POST) — approve a discovered peer&lt;br /&gt;
* &amp;lt;code&amp;gt;/admin/peers/revoke?key=YOUR_ADMIN_KEY&amp;amp;domain=peer.com&amp;lt;/code&amp;gt; (POST) — revoke approval&lt;br /&gt;
* &amp;lt;code&amp;gt;/admin/peers/rescan?key=YOUR_ADMIN_KEY&amp;lt;/code&amp;gt; (POST) — force a Hive directory rescan&lt;br /&gt;
&lt;br /&gt;
=== Operator Tool Pages ===&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;/rate-editor.html&amp;lt;/code&amp;gt; — generate your &amp;lt;code&amp;gt;v4call-rates&amp;lt;/code&amp;gt; Hive post&lt;br /&gt;
* &amp;lt;code&amp;gt;/server-sign.html&amp;lt;/code&amp;gt; — generate your signed federation verify file&lt;br /&gt;
* &amp;lt;code&amp;gt;/server-announce.html&amp;lt;/code&amp;gt; — publish your server announcement on Hive&lt;br /&gt;
* &amp;lt;code&amp;gt;/admin-peers.html&amp;lt;/code&amp;gt; — UI for federation peer approve/revoke/rescan&lt;br /&gt;
* &amp;lt;code&amp;gt;/info.html&amp;lt;/code&amp;gt; — public landing page (shown to users who hit the basic-auth login cancel)&lt;br /&gt;
&lt;br /&gt;
== Updating Your Server ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important:&#039;&#039;&#039; Always use &amp;lt;code&amp;gt;docker compose down&amp;lt;/code&amp;gt; before rebuilding. Without this step, Docker may reuse the old container even after a rebuild, and your changes will not take effect.&lt;br /&gt;
&lt;br /&gt;
 cd /opt/v4call&lt;br /&gt;
 docker compose down&lt;br /&gt;
 docker compose build --no-cache&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
&lt;br /&gt;
Your data, SQLite databases and &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; config are preserved — they live in &amp;lt;code&amp;gt;data/logs/&amp;lt;/code&amp;gt; which is a mounted volume.&lt;br /&gt;
&lt;br /&gt;
To pull updates from GitHub and deploy:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/v4call&lt;br /&gt;
 git pull&lt;br /&gt;
 docker compose down&lt;br /&gt;
 docker compose build --no-cache&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
&lt;br /&gt;
To push your own customisations to GitHub:&lt;br /&gt;
&lt;br /&gt;
 # On your local computer after making changes:&lt;br /&gt;
 git add .&lt;br /&gt;
 git commit -m &amp;quot;describe what you changed&amp;quot;&lt;br /&gt;
 git push&lt;br /&gt;
&lt;br /&gt;
 # On the VPS:&lt;br /&gt;
 cd /opt/v4call&lt;br /&gt;
 git pull&lt;br /&gt;
 docker compose down&lt;br /&gt;
 docker compose build --no-cache&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
&lt;br /&gt;
== Common Problems and Fixes ==&lt;br /&gt;
&lt;br /&gt;
=== Changes not showing after rebuild ===&lt;br /&gt;
&lt;br /&gt;
If you edited &amp;lt;code&amp;gt;server.js&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;index.html&amp;lt;/code&amp;gt; but changes are not visible, you probably forgot to bring Docker down first. &amp;lt;code&amp;gt;docker compose restart&amp;lt;/code&amp;gt; and even &amp;lt;code&amp;gt;docker compose up -d --build&amp;lt;/code&amp;gt; can reuse old containers.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
 docker compose down&lt;br /&gt;
 docker compose build --no-cache&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
&lt;br /&gt;
=== SqliteError: unable to open database file ===&lt;br /&gt;
&lt;br /&gt;
The app container runs as UID 1000 but the logs directory was created by root. Fix:&lt;br /&gt;
&lt;br /&gt;
 chown -R 1000:1000 /opt/v4call/data/logs&lt;br /&gt;
 docker compose restart app&lt;br /&gt;
&lt;br /&gt;
=== Certbot says &amp;quot;No renewals were attempted&amp;quot; ===&lt;br /&gt;
&lt;br /&gt;
The certbot container&#039;s default behaviour is a renewal loop. Your &amp;lt;code&amp;gt;certonly&amp;lt;/code&amp;gt; command is being ignored. Always use &amp;lt;code&amp;gt;--entrypoint certbot&amp;lt;/code&amp;gt; to override it:&lt;br /&gt;
&lt;br /&gt;
 docker compose run --rm --entrypoint certbot certbot certonly ...&lt;br /&gt;
&lt;br /&gt;
Without &amp;lt;code&amp;gt;--entrypoint certbot&amp;lt;/code&amp;gt; the container runs its renewal script instead of your command.&lt;br /&gt;
&lt;br /&gt;
=== Nginx crashes with &amp;quot;cannot load certificate: No such file&amp;quot; ===&lt;br /&gt;
&lt;br /&gt;
You added the HTTPS server block before getting the certificate. Nginx reads all server blocks at startup — if the cert file doesn&#039;t exist, the entire process fails.&lt;br /&gt;
&lt;br /&gt;
Fix: revert nginx config to HTTP-only (Step 9), restart Nginx, get the certificate (Step 12), then re-add HTTPS (Step 13).&lt;br /&gt;
&lt;br /&gt;
=== Webroot challenge test returns nothing ===&lt;br /&gt;
&lt;br /&gt;
Nginx is not running. Check:&lt;br /&gt;
&lt;br /&gt;
 docker compose ps&lt;br /&gt;
 docker compose logs nginx&lt;br /&gt;
&lt;br /&gt;
If Nginx is restarting — it has the HTTPS config with a missing cert file. Use the HTTP-only config.&lt;br /&gt;
&lt;br /&gt;
=== &amp;quot;Escrow key does NOT match&amp;quot; warning on startup ===&lt;br /&gt;
&lt;br /&gt;
The key in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; is the wrong type. You need the &#039;&#039;&#039;active&#039;&#039;&#039; private key, not the posting or owner key. Find it in your Hive wallet → Keys &amp;amp; Permissions → Active. It starts with &amp;lt;code&amp;gt;5K&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
=== Site unreachable on port 443 ===&lt;br /&gt;
&lt;br /&gt;
Check that the certificate was issued:&lt;br /&gt;
&lt;br /&gt;
 ls /opt/v4call/data/certbot/conf/live/&lt;br /&gt;
&lt;br /&gt;
Should show a folder with your domain name. If empty — go back to Step 12.&lt;br /&gt;
&lt;br /&gt;
=== npm ci error during Docker build ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;npm ci&amp;lt;/code&amp;gt; command requires a &amp;lt;code&amp;gt;package-lock.json&amp;lt;/code&amp;gt; file. The project uses &amp;lt;code&amp;gt;npm install&amp;lt;/code&amp;gt; instead. If you see this error, check your &amp;lt;code&amp;gt;Dockerfile&amp;lt;/code&amp;gt; — it should say &amp;lt;code&amp;gt;npm install&amp;lt;/code&amp;gt; not &amp;lt;code&amp;gt;npm ci&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
=== Custom token payments not working ===&lt;br /&gt;
&lt;br /&gt;
If token rates are detected but payments fail:&lt;br /&gt;
* Check the escrow account holds the token — send some tokens to your escrow account on Hive-Engine&lt;br /&gt;
* Check the token symbol matches exactly (case-sensitive) between the rates post and Hive-Engine&lt;br /&gt;
* Check the server logs: &amp;lt;code&amp;gt;docker compose logs app | grep -i &amp;quot;token\|cnoobs\|escrow-token&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
* The Hive-Engine API endpoint must be &amp;lt;code&amp;gt;https://api.hive-engine.com/rpc/contracts&amp;lt;/code&amp;gt; — this is built into the code&lt;br /&gt;
&lt;br /&gt;
=== [encrypted — unlock with 🔑 key panel to read] ===&lt;br /&gt;
&lt;br /&gt;
You logged in with Hive Keychain. Keychain does not expose private keys, so encrypted messages cannot be decrypted without your posting key. Enter your posting key in the 🔑 panel at the bottom of the online users list. The key stays in browser session memory only — it is needed once per session.&lt;br /&gt;
&lt;br /&gt;
=== Federation peer verification fails with HTTP 404 ===&lt;br /&gt;
&lt;br /&gt;
Logs show:&lt;br /&gt;
 [federation] ✗ Peer verification failed for X: Cannot fetch verify.json: HTTP 404&lt;br /&gt;
&lt;br /&gt;
The peer&#039;s &amp;lt;code&amp;gt;/.well-known/v4call-server.json&amp;lt;/code&amp;gt; file isn&#039;t being served. Common causes:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Wrong directory name&#039;&#039;&#039; — must be &amp;lt;code&amp;gt;.well-known&amp;lt;/code&amp;gt; with a hyphen, not &amp;lt;code&amp;gt;.well_known&amp;lt;/code&amp;gt; with an underscore (RFC 8615).&lt;br /&gt;
* &#039;&#039;&#039;Wrong filename&#039;&#039;&#039; — must be &amp;lt;code&amp;gt;v4call-server.json&amp;lt;/code&amp;gt;, not &amp;lt;code&amp;gt;verify.json&amp;lt;/code&amp;gt; or anything else. The verifier code looks for this exact name.&lt;br /&gt;
* &#039;&#039;&#039;Not in the Docker image&#039;&#039;&#039; — file lives at &amp;lt;code&amp;gt;public/.well-known/v4call-server.json&amp;lt;/code&amp;gt; in the repo and is copied into the image at build time. After placing the file, rebuild: &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Placeholder not replaced&#039;&#039;&#039; — the repo ships with a tiny placeholder. You must overwrite it with your own signed file from &amp;lt;code&amp;gt;/server-sign.html&amp;lt;/code&amp;gt; before federation will work.&lt;br /&gt;
&lt;br /&gt;
Test from outside the container:&lt;br /&gt;
 curl https://yourdomain.com/.well-known/v4call-server.json&lt;br /&gt;
&lt;br /&gt;
Should return your signed JSON, not &amp;lt;code&amp;gt;404&amp;lt;/code&amp;gt; or the placeholder.&lt;br /&gt;
&lt;br /&gt;
=== Federation connection flapping (rapid disconnect/reconnect loop) ===&lt;br /&gt;
&lt;br /&gt;
If logs show repeated &amp;lt;code&amp;gt;Outbound connected&amp;lt;/code&amp;gt; followed by &amp;lt;code&amp;gt;Disconnected — retry in 2s&amp;lt;/code&amp;gt; with no successful handshake, check verify.json:&lt;br /&gt;
&lt;br /&gt;
* Is the file present at the path above?&lt;br /&gt;
* Does its &amp;lt;code&amp;gt;signature&amp;lt;/code&amp;gt; match the Hive account&#039;s posting key? Test by pasting the JSON into the &#039;&#039;&#039;Verify&#039;&#039;&#039; panel on &amp;lt;code&amp;gt;/server-sign.html&amp;lt;/code&amp;gt; — should say &amp;quot;✓ Signature valid&amp;quot;.&lt;br /&gt;
* Does &amp;lt;code&amp;gt;hive_account&amp;lt;/code&amp;gt; in the JSON match &amp;lt;code&amp;gt;SERVER_HIVE_ACCOUNT&amp;lt;/code&amp;gt; in the peer&#039;s .env? They must match.&lt;br /&gt;
&lt;br /&gt;
=== Federation peer connects but my user can&#039;t be seen on the other side ===&lt;br /&gt;
&lt;br /&gt;
If two servers connect successfully but a user on Server A doesn&#039;t appear in Server B&#039;s lobby, check:&lt;br /&gt;
&lt;br /&gt;
* Server A logs: &amp;lt;code&amp;gt;[federation] → user-online @username → 1 peer(s)&amp;lt;/code&amp;gt; — confirms A broadcast the presence&lt;br /&gt;
* Server B logs: &amp;lt;code&amp;gt;[federation] ← user-online @username@A.com&amp;lt;/code&amp;gt; — confirms B received it&lt;br /&gt;
&lt;br /&gt;
If A logs the broadcast but B never received it, the federation socket might be one-way. Check both servers have approved each other (visible at &amp;lt;code&amp;gt;/admin-peers.html&amp;lt;/code&amp;gt;).&lt;br /&gt;
&lt;br /&gt;
=== Federated paid call/DM fails silently after payment ===&lt;br /&gt;
&lt;br /&gt;
Most common cause: the user&#039;s &amp;lt;code&amp;gt;v4call-rates&amp;lt;/code&amp;gt; post declares an &amp;lt;code&amp;gt;ESCROW:&amp;lt;/code&amp;gt; account whose active key isn&#039;t on their home server. The caller&#039;s payment lands in that escrow correctly, but no server can disburse from it.&lt;br /&gt;
&lt;br /&gt;
Fix: have the user re-post their rates with an &amp;lt;code&amp;gt;ESCROW:&amp;lt;/code&amp;gt; matching their home server&#039;s &amp;lt;code&amp;gt;ESCROW_ACCOUNT&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;. Or migrate that escrow account&#039;s active key to the right server.&lt;br /&gt;
&lt;br /&gt;
The federation handshake also surfaces this as a clear error message via the escrow-mismatch guard — check the caller-side server logs for it.&lt;br /&gt;
&lt;br /&gt;
=== Discovery scanner finds 0 posts ===&lt;br /&gt;
&lt;br /&gt;
If &amp;lt;code&amp;gt;/admin-peers.html&amp;lt;/code&amp;gt; shows &amp;quot;No v4call-server posts discovered yet&amp;quot; even after rescanning:&lt;br /&gt;
&lt;br /&gt;
* Click &#039;&#039;&#039;🔍 Rescan Hive now&#039;&#039;&#039; and watch logs:  &amp;lt;code&amp;gt;docker compose logs -f app | grep discovery&amp;lt;/code&amp;gt;&lt;br /&gt;
* Expected: &amp;lt;code&amp;gt;[discovery] Hive returned N post(s) under v4call-server tag&amp;lt;/code&amp;gt;&lt;br /&gt;
* If N = 0, no Hive posts exist with that tag yet — publish your own via &amp;lt;code&amp;gt;/server-announce.html&amp;lt;/code&amp;gt;&lt;br /&gt;
* If you see &amp;lt;code&amp;gt;[discovery] No response from Hive tag query&amp;lt;/code&amp;gt;, the Hive API is unreachable from inside the container. Test container DNS / outbound HTTPS by hitting any external HTTPS URL from the container.&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Command !! What it does&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose ps&amp;lt;/code&amp;gt; || Show status of all containers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose logs app&amp;lt;/code&amp;gt; || Show app logs&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose logs nginx&amp;lt;/code&amp;gt; || Show Nginx logs&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose logs -f app&amp;lt;/code&amp;gt; || Watch live logs (Ctrl+C to stop)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose down&amp;lt;/code&amp;gt; || Stop everything (&#039;&#039;&#039;always do this before rebuilding&#039;&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose build --no-cache&amp;lt;/code&amp;gt; || Rebuild without using cached layers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose up -d&amp;lt;/code&amp;gt; || Start everything in background&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt; || Full rebuild cycle (use after any code change)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose restart nginx&amp;lt;/code&amp;gt; || Restart Nginx after config-only changes (no rebuild needed)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;chown -R 1000:1000 /opt/v4call/data/logs&amp;lt;/code&amp;gt; || Fix SQLite write permissions&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose run --rm --entrypoint certbot certbot certificates&amp;lt;/code&amp;gt; || List SSL certificates&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;curl http://yourdomain.com/.well-known/acme-challenge/testfile&amp;lt;/code&amp;gt; || Test certbot webroot works&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose logs app &amp;amp;#124; grep -i &amp;quot;token\|escrow&amp;quot;&amp;lt;/code&amp;gt; || Check token payment logs&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose logs -f app &amp;amp;#124; grep -E &amp;quot;\[federation\]&amp;amp;#124;\[discovery\]&amp;quot;&amp;lt;/code&amp;gt; || Watch federation activity&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;curl https://yourdomain.com/.well-known/v4call-server.json&amp;lt;/code&amp;gt; || Check verify file is served&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;https://yourdomain.com/server-sign.html&amp;lt;/code&amp;gt; || Generate signed federation verify file&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;https://yourdomain.com/server-announce.html&amp;lt;/code&amp;gt; || Publish server to Hive directory&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;https://yourdomain.com/admin-peers.html&amp;lt;/code&amp;gt; || Manage federation peer approvals&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Optional: Password Protect Your Server During Testing ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;strong&amp;gt;Optional: Password Protect Your Server During Testing (HTTP Basic Auth)&amp;lt;/strong&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
During development and testing you may want to restrict access so only people you invite can use your server. This uses Nginx HTTP Basic Auth — a simple username and password prompt that appears before the v4call login screen.&lt;br /&gt;
&lt;br /&gt;
When a visitor cancels the login prompt they are shown a public &amp;lt;code&amp;gt;info.html&amp;lt;/code&amp;gt; page where they can read about the project and request access.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important&#039;&#039;&#039;: do NOT add basic auth to the &amp;lt;code&amp;gt;/federation&amp;lt;/code&amp;gt; location block. Federation peers cannot supply HTTP credentials and the connection will fail. The auth lines below are added only to &amp;lt;code&amp;gt;/socket.io/&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;/&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 1 — Install the htpasswd tool&amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;apt install -y apache2-utils&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 2 — Create the password file and add your first user &amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;htpasswd -c /opt/v4call/nginx/.htpasswd yourusername&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You will be prompted to enter and confirm a password. The &amp;lt;code&amp;gt;-c&amp;lt;/code&amp;gt; flag creates the file. Do not use &amp;lt;code&amp;gt;-c&amp;lt;/code&amp;gt; again or it will overwrite the file and delete existing users.&lt;br /&gt;
&lt;br /&gt;
To add more users later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;htpasswd /opt/v4call/nginx/.htpasswd anotherusername&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To remove a user:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;htpasswd -D /opt/v4call/nginx/.htpasswd username&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 3 — Create the public info page &amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;nano /opt/v4call/public/info.html&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Paste your HTML content — a page explaining the project and how to request access. This page is served publicly without a password so visitors who cancel the login prompt can still read it.&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 4 — Mount the files into the Nginx container &amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Edit &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt; and add two lines to the nginx volumes section:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
  nginx:&lt;br /&gt;
    volumes:&lt;br /&gt;
      - ./nginx/v4call.conf:/etc/nginx/conf.d/default.conf:ro&lt;br /&gt;
      - ./nginx/.htpasswd:/etc/nginx/.htpasswd:ro          # add this&lt;br /&gt;
      - ./public/info.html:/usr/share/nginx/html/info.html:ro  # add this&lt;br /&gt;
      - ./data/certbot/conf:/etc/letsencrypt:ro&lt;br /&gt;
      - ./data/certbot/www:/var/www/certbot:ro&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 5 — Enable auth in your Nginx config &amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The HTTPS config from Step 13 already includes the &amp;lt;code&amp;gt;auth_basic&amp;lt;/code&amp;gt; lines, just commented out. Open &amp;lt;code&amp;gt;nginx/v4call.conf&amp;lt;/code&amp;gt; and uncomment them in BOTH the &amp;lt;code&amp;gt;/socket.io/&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;/&amp;lt;/code&amp;gt; location blocks (4 lines total, two pairs):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
        auth_basic           &amp;quot;v4call — Private Testing&amp;quot;;&lt;br /&gt;
        auth_basic_user_file /etc/nginx/.htpasswd;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Do not&#039;&#039;&#039; uncomment auth in the &amp;lt;code&amp;gt;/federation&amp;lt;/code&amp;gt; block — leave it as-is.&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 6 — Recreate the Nginx container to pick up the new volume mounts &amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important&#039;&#039;&#039;: &amp;lt;code&amp;gt;docker compose restart nginx&amp;lt;/code&amp;gt; is not enough — it reuses the old container and ignores new volume mounts. You must recreate it:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
After this, verify the files are mounted inside the container:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker compose exec nginx ls /usr/share/nginx/html/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You should see &amp;lt;code&amp;gt;info.html&amp;lt;/code&amp;gt; listed alongside &amp;lt;code&amp;gt;50x.html&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;index.html&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 7 — Test it works &amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;curl -o /dev/null -s -w &amp;quot;%{http_code}&amp;quot; https://call.yourdomain.com/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Should return &amp;lt;code&amp;gt;401&amp;lt;/code&amp;gt; (login required).&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;curl -o /dev/null -s -w &amp;quot;%{http_code}&amp;quot; https://call.yourdomain.com/info.html&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Should return &amp;lt;code&amp;gt;200&amp;lt;/code&amp;gt; (public, no auth needed).&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;curl -u youruser:yourpassword -o /dev/null -s -w &amp;quot;%{http_code}&amp;quot; https://call.yourdomain.com/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Should return &amp;lt;code&amp;gt;200&amp;lt;/code&amp;gt; (correct credentials accepted).&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Managing users without restarting &amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
After adding or removing users from the &amp;lt;code&amp;gt;.htpasswd&amp;lt;/code&amp;gt; file, reload Nginx config without downtime:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker compose exec nginx nginx -s reload&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
No restart needed — Nginx re-reads the &amp;lt;code&amp;gt;.htpasswd&amp;lt;/code&amp;gt; file on every request anyway.&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Keep .htpasswd out of GitHub &amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The password file should never be committed to your repository. It&#039;s already in the project&#039;s &amp;lt;code&amp;gt;.gitignore&amp;lt;/code&amp;gt;, but if you forked, double-check:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;echo &amp;quot;nginx/.htpasswd&amp;quot; &amp;gt;&amp;gt; /opt/v4call/.gitignore&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Removing auth when you go public &amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
When you are ready to open your server to everyone, simply re-comment the &amp;lt;code&amp;gt;auth_basic&amp;lt;/code&amp;gt; lines from &amp;lt;code&amp;gt;nginx/v4call.conf&amp;lt;/code&amp;gt; and reload:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker compose exec nginx nginx -s reload&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
No other changes needed.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
&#039;&#039;&#039;How to check if anyone tried to log in (failed attempts) or successfully logged in with .htpasswd on Nginx in Docker&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important Tip:&#039;&#039;&#039; Always run these commands in the same folder where your &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt; file is located. If you are in the wrong directory the commands will not find your container and nothing will show up.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;&#039;&#039; Basic Commands &#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
; &#039;&#039;&#039;See everything live (good while testing)&#039;&#039;&#039;&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose logs -f nginx&amp;lt;/code&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
: &amp;lt;code&amp;gt;-f&amp;lt;/code&amp;gt; means &amp;quot;follow/live&amp;quot; – new log lines appear automatically. Remove &amp;lt;code&amp;gt;-f&amp;lt;/code&amp;gt; if you only want to read the current logs once and stop.&lt;br /&gt;
&lt;br /&gt;
; &#039;&#039;&#039;Best command to watch failed login attempts (people guessing passwords)&#039;&#039;&#039;&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose logs -f nginx | grep -E &amp;quot;mismatch|not found|401&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; &#039;&#039;&#039;See who successfully logged in&#039;&#039;&#039;&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose logs nginx | grep &amp;quot;remote_user&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; &#039;&#039;&#039;Combined view – most useful for daily checking (failed + successful)&#039;&#039;&#039;&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose logs -f --tail=100 nginx | grep -E &amp;quot;(mismatch|not found|remote_user|401)&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;&#039;&#039; What the Logs Look Like &#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Failed attempt (wrong password):&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;2026/04/17 01:23:45 [error] ... user &amp;quot;admin&amp;quot;: password mismatch, client: 185.123.45.67, ...&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Failed attempt (wrong username):&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;2026/04/17 01:24:12 [error] ... user &amp;quot;hacker123&amp;quot; was not found in &amp;quot;/etc/nginx/.htpasswd&amp;quot;, client: 45.67.89.10, ...&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Successful login:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;... &amp;quot;GET /protected/ HTTP/1.1&amp;quot; 200 ... remote_user: &amp;quot;myuser&amp;quot; ...&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;&#039;&#039; How to Customise These Commands &#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Change &amp;lt;code&amp;gt;nginx&amp;lt;/code&amp;gt; to the exact name of your service if it is different in docker-compose.yml.&lt;br /&gt;
* Remove &amp;lt;code&amp;gt;-f&amp;lt;/code&amp;gt; to read the full log once without live following.&lt;br /&gt;
* Change &amp;lt;code&amp;gt;--tail=100&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;--tail=500&amp;lt;/code&amp;gt; (or any number) to show more or fewer old lines.&lt;br /&gt;
* Add or remove words in the &amp;lt;code&amp;gt;grep&amp;lt;/code&amp;gt; part to filter differently.&lt;br /&gt;
  Examples:&lt;br /&gt;
  * Only failed attempts: &amp;lt;code&amp;gt;grep -E &amp;quot;mismatch|not found&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
  * Only 401 errors: &amp;lt;code&amp;gt;grep &amp;quot; 401 &amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
  * Everything auth related: &amp;lt;code&amp;gt;grep -E &amp;quot;(auth|password|mismatch|not found|remote_user)&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;&#039;&#039;Quick Copy-Paste Commands &#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
# Watch failed guesses live&lt;br /&gt;
docker compose logs -f nginx | grep -E &amp;quot;mismatch|not found|401&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Check successful logins&lt;br /&gt;
docker compose logs nginx | grep &amp;quot;remote_user&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Combined quick check (recommended)&lt;br /&gt;
docker compose logs -f --tail=100 nginx | grep -E &amp;quot;(mismatch|not found|remote_user|401)&amp;quot;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
[[Category:Docker]]&lt;br /&gt;
[[Category:Ubuntu]]&lt;br /&gt;
[[Category:Hive]]&lt;br /&gt;
[[Category:v4call]]&lt;br /&gt;
[[Category:WebRTC]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Basic_Html_JavaScript_POPUP_warning_Accept&amp;diff=741</id>
		<title>Basic Html JavaScript POPUP warning Accept</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Basic_Html_JavaScript_POPUP_warning_Accept&amp;diff=741"/>
		<updated>2026-04-19T19:24:55Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;3 parts  ==Part one== * This is placed in the &amp;lt;head&amp;gt; &amp;lt;/head&amp;gt; at the top of index.html contains css &amp;lt;pre&amp;gt; &amp;lt;style&amp;gt;   #overlay {     position: fixed; top: 0; left: 0; width: 100%; height: 100%;     background: rgba(0,0,0,0.85); color: white; z-index: 10000;     display: flex; align-items: center; justify-content: center; text-align: center;   }   .popup-box { background: #222; padding: 30px; border-radius: 10px; border: 1px solid #444; }   button { padding: 10px 20px; curso...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;3 parts&lt;br /&gt;
&lt;br /&gt;
==Part one==&lt;br /&gt;
* This is placed in the &amp;lt;head&amp;gt; &amp;lt;/head&amp;gt; at the top of index.html contains css&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;style&amp;gt;&lt;br /&gt;
  #overlay {&lt;br /&gt;
    position: fixed; top: 0; left: 0; width: 100%; height: 100%;&lt;br /&gt;
    background: rgba(0,0,0,0.85); color: white; z-index: 10000;&lt;br /&gt;
    display: flex; align-items: center; justify-content: center; text-align: center;&lt;br /&gt;
  }&lt;br /&gt;
  .popup-box { background: #222; padding: 30px; border-radius: 10px; border: 1px solid #444; }&lt;br /&gt;
  button { padding: 10px 20px; cursor: pointer; background: #007bff; color: white; border: none; border-radius: 5px; }&lt;br /&gt;
  .hidden { display: none !important; }&lt;br /&gt;
&amp;lt;/style&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
==part two==&lt;br /&gt;
* This is placed in/at the top of the &amp;lt;body&amp;gt; &amp;lt;/body&amp;gt; section, contains message.&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!-- pop up cookies and other 2/3--&amp;gt;&lt;br /&gt;
&amp;lt;div id=&amp;quot;overlay&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;popup-box&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;h2&amp;gt;Welcome to Complete Noobs!&amp;lt;/h2&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;We use cookies to track traffic. Are you 21 or older?&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;By accepting you are entering at your own risk&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;button onclick=&amp;quot;acceptAndHide()&amp;quot;&amp;gt;Yes, I Accept &amp;amp; I&#039;m Over 21 &amp;amp; am aware you are noobs and are still learning i enter at my own risk&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;→ &amp;lt;a href=&amp;quot;https://www.google.com&amp;quot; &amp;gt;No Thank You - take me to Google&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==part 3==&lt;br /&gt;
* This is placed in/at the bottom of the &amp;lt;body&amp;gt; &amp;lt;/body&amp;gt; section, contains js script.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!-- pop up cookies and other  3/3--&amp;gt;&lt;br /&gt;
&amp;lt;script&amp;gt;&lt;br /&gt;
  function acceptAndHide() {&lt;br /&gt;
    // Save the choice in the browser&#039;s &amp;quot;localStorage&amp;quot; so it doesn&#039;t pop up again&lt;br /&gt;
    localStorage.setItem(&#039;gate_passed&#039;, &#039;true&#039;);&lt;br /&gt;
    document.getElementById(&#039;overlay&#039;).classList.add(&#039;hidden&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  // On page load, check if they already clicked &amp;quot;Yes&amp;quot;&lt;br /&gt;
  window.onload = function() {&lt;br /&gt;
    if (localStorage.getItem(&#039;gate_passed&#039;) === &#039;true&#039;) {&lt;br /&gt;
      document.getElementById(&#039;overlay&#039;).classList.add(&#039;hidden&#039;);&lt;br /&gt;
    }&lt;br /&gt;
  };&lt;br /&gt;
&amp;lt;/script&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
==use case==&lt;br /&gt;
* Sample use case&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br /&gt;
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;head&amp;gt;&lt;br /&gt;
    &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1.0&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;title&amp;gt;My Projects - Status Update&amp;lt;/title&amp;gt;&lt;br /&gt;
    &amp;lt;style&amp;gt;&lt;br /&gt;
        body {&lt;br /&gt;
            font-family: Arial, Helvetica, sans-serif;&lt;br /&gt;
            max-width: 800px;&lt;br /&gt;
            margin: 40px auto;&lt;br /&gt;
            padding: 20px;&lt;br /&gt;
            line-height: 1.6;&lt;br /&gt;
            color: #333;&lt;br /&gt;
        }&lt;br /&gt;
        h1 {&lt;br /&gt;
            text-align: center;&lt;br /&gt;
            color: #222;&lt;br /&gt;
        }&lt;br /&gt;
        h2 {&lt;br /&gt;
            color: #444;&lt;br /&gt;
            border-bottom: 2px solid #eee;&lt;br /&gt;
            padding-bottom: 8px;&lt;br /&gt;
        }&lt;br /&gt;
        a {&lt;br /&gt;
            color: #0066cc;&lt;br /&gt;
            text-decoration: none;&lt;br /&gt;
        }&lt;br /&gt;
        a:hover {&lt;br /&gt;
            text-decoration: underline;&lt;br /&gt;
        }&lt;br /&gt;
        .status {&lt;br /&gt;
            font-style: italic;&lt;br /&gt;
            color: #555;&lt;br /&gt;
        }&lt;br /&gt;
    &amp;lt;/style&amp;gt;&lt;br /&gt;
&amp;lt;!-- pop up cookies and other 1/3--&amp;gt;&lt;br /&gt;
&amp;lt;style&amp;gt;&lt;br /&gt;
  #overlay {&lt;br /&gt;
    position: fixed; top: 0; left: 0; width: 100%; height: 100%;&lt;br /&gt;
    background: rgba(0,0,0,0.85); color: white; z-index: 10000;&lt;br /&gt;
    display: flex; align-items: center; justify-content: center; text-align: center;&lt;br /&gt;
  }&lt;br /&gt;
  .popup-box { background: #222; padding: 30px; border-radius: 10px; border: 1px solid #444; }&lt;br /&gt;
  button { padding: 10px 20px; cursor: pointer; background: #007bff; color: white; border: none; border-radius: 5px; }&lt;br /&gt;
  .hidden { display: none !important; }&lt;br /&gt;
&amp;lt;/style&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/head&amp;gt;&lt;br /&gt;
&amp;lt;!--24-06-25 adding google analytic  --&amp;gt;&lt;br /&gt;
&amp;lt;!-- Google tag (gtag.js) --&amp;gt;&lt;br /&gt;
&amp;lt;script async src=&amp;quot;https://www.googletagmanager.com/gtag/js?id=G-YXYQE65XY1&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;&lt;br /&gt;
&amp;lt;script&amp;gt;&lt;br /&gt;
  window.dataLayer = window.dataLayer || [];&lt;br /&gt;
  function gtag(){dataLayer.push(arguments);}&lt;br /&gt;
  gtag(&#039;js&#039;, new Date());&lt;br /&gt;
&lt;br /&gt;
  gtag(&#039;config&#039;, &#039;G-YXYQE65XY1&#039;);&lt;br /&gt;
&amp;lt;/script&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;body&amp;gt;&lt;br /&gt;
&amp;lt;!-- pop up cookies and other 2/3--&amp;gt;&lt;br /&gt;
&amp;lt;div id=&amp;quot;overlay&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;popup-box&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;h2&amp;gt;Welcome to Complete Noobs!&amp;lt;/h2&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;We use cookies to track traffic. Are you 21 or older?&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;By accepting you are entering at your own risk&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;button onclick=&amp;quot;acceptAndHide()&amp;quot;&amp;gt;Yes, I Accept &amp;amp; I&#039;m Over 21 &amp;amp; am aware you are noobs and are still learning i enter at my own risk&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;→ &amp;lt;a href=&amp;quot;https://www.google.com&amp;quot; &amp;gt;No Thank You - take me to Google&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;h1&amp;gt;Project Status&amp;lt;/h1&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;h2&amp;gt;CompleteNoobs&amp;lt;/h2&amp;gt;&lt;br /&gt;
    &amp;lt;p class=&amp;quot;status&amp;quot;&amp;gt;Currently on hold due to lack of free time.&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;It will be relaunched as &amp;lt;strong&amp;gt;cnoobs.com&amp;lt;/strong&amp;gt; running inside a Docker container.&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;Future plans include a possible soft-fork of MediaWiki that allows Hive accounts holding a certain amount of CNOOBS coins to post and edit pages. &lt;br /&gt;
       I&#039;m also interested in integrating the Hive rewards system for content creators and exploring reward splits between users.&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;Additional custom features being considered:&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;ul&amp;gt;&lt;br /&gt;
        &amp;lt;li&amp;gt;Custom tags for embedding IPFS content to keep the wiki database smaller and more portable:&amp;lt;/li&amp;gt;&lt;br /&gt;
        &amp;lt;ul&amp;gt;&lt;br /&gt;
            &amp;lt;li&amp;gt;&amp;lt;code&amp;gt;&amp;amp;lt;ipfs_video&amp;amp;gt;ipfs_addr&amp;amp;lt;/ipfs_video&amp;amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
            &amp;lt;li&amp;gt;&amp;lt;code&amp;gt;&amp;amp;lt;ipfs_pic&amp;amp;gt;ipfs_addr&amp;amp;lt;/ipfs_pic&amp;amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
            &amp;lt;li&amp;gt;&amp;lt;code&amp;gt;&amp;amp;lt;ipfs_audio&amp;amp;gt;ipfs_addr&amp;amp;lt;/ipfs_audio&amp;amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
            &amp;lt;li&amp;gt;&amp;lt;code&amp;gt;&amp;amp;lt;ipfs_file&amp;amp;gt;ipfs_addr&amp;amp;lt;/ipfs_file&amp;amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
        &amp;lt;/ul&amp;gt;&lt;br /&gt;
    &amp;lt;/ul&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;The old draft remains available but is unmaintained:&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;→ &amp;lt;a href=&amp;quot;https://www.completenoobs.com/noobs/Main_Page&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;CompleteNoobs.com (old wiki)&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;→ &amp;lt;a href=&amp;quot;https://xml.completenoobs.com&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;xml.CompleteNoobs.com (old xml downloads)&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;h2&amp;gt;n33b.com&amp;lt;/h2&amp;gt;&lt;br /&gt;
    &amp;lt;p class=&amp;quot;status&amp;quot;&amp;gt;Currently on hold.&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;It will &amp;lt;strong&amp;gt;not&amp;lt;/strong&amp;gt; become a coin project. It will return to its original purpose: a purely educational site for people who like to learn by tinkering and hands-on experimentation.&amp;lt;/p&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;h2&amp;gt;v4call.com&amp;lt;/h2&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;→ &amp;lt;a href=&amp;quot;https://v4call.com&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;v4call.com&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p class=&amp;quot;status&amp;quot;&amp;gt;idk — tinker gonna tinker 😄&amp;lt;/p&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;hr&amp;gt;&lt;br /&gt;
    &amp;lt;p style=&amp;quot;text-align:center; color:#777; font-size:0.9em;&amp;quot;&amp;gt;&lt;br /&gt;
        Last updated: April 2026&lt;br /&gt;
    &amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;!-- pop up cookies and other  3/3--&amp;gt;&lt;br /&gt;
&amp;lt;script&amp;gt;&lt;br /&gt;
  function acceptAndHide() {&lt;br /&gt;
    // Save the choice in the browser&#039;s &amp;quot;localStorage&amp;quot; so it doesn&#039;t pop up again&lt;br /&gt;
    localStorage.setItem(&#039;gate_passed&#039;, &#039;true&#039;);&lt;br /&gt;
    document.getElementById(&#039;overlay&#039;).classList.add(&#039;hidden&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  // On page load, check if they already clicked &amp;quot;Yes&amp;quot;&lt;br /&gt;
  window.onload = function() {&lt;br /&gt;
    if (localStorage.getItem(&#039;gate_passed&#039;) === &#039;true&#039;) {&lt;br /&gt;
      document.getElementById(&#039;overlay&#039;).classList.add(&#039;hidden&#039;);&lt;br /&gt;
    }&lt;br /&gt;
  };&lt;br /&gt;
&amp;lt;/script&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/body&amp;gt;&lt;br /&gt;
&amp;lt;/html&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=V4CALL&amp;diff=740</id>
		<title>V4CALL</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=V4CALL&amp;diff=740"/>
		<updated>2026-04-19T14:17:40Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;{{:LICENCE_HEADER_MIT}}   = v4call — Deploy Your Own Server on Ubuntu 24.04 with Docker =  From CompleteNoobs  This guide walks through deploying your own v4call server from scratch on a fresh Vultr Ubuntu 24.04 VPS — from first login to a working HTTPS video/audio calling service on your own domain.  v4call is an open-source, decentralised video and audio calling platform that uses Hive blockchain for identity and HBD micropayments. It supports custom Hive-Engine to...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_MIT}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
= v4call — Deploy Your Own Server on Ubuntu 24.04 with Docker =&lt;br /&gt;
&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
&lt;br /&gt;
This guide walks through deploying your own v4call server from scratch on a fresh Vultr Ubuntu 24.04 VPS — from first login to a working HTTPS video/audio calling service on your own domain.&lt;br /&gt;
&lt;br /&gt;
v4call is an open-source, decentralised video and audio calling platform that uses Hive blockchain for identity and HBD micropayments. It supports custom Hive-Engine token payments, encrypted direct messages with persistent chat history, voice-only and video calls, and a free-market platform fee system. Fork it, run your own server, keep all your platform fees, join the federation, federation coming (unknown time) kismet.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Source code&#039;&#039;&#039;: [https://github.com/CompleteNoobs/v4call https://github.com/CompleteNoobs/v4call]&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;End result&#039;&#039;&#039;: A working v4call server at &amp;lt;code&amp;gt;https://call.yourdomain.com&amp;lt;/code&amp;gt; that you can log into with a Hive account.&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
&lt;br /&gt;
* [[#What_You_Need|1 What You Need]]&lt;br /&gt;
* [[#Step_1:_Create_Your_Vultr_VPS|2 Step 1: Create Your Vultr VPS]]&lt;br /&gt;
* [[#Step_2:_Point_Your_Domain_at_the_VPS|3 Step 2: Point Your Domain at the VPS]]&lt;br /&gt;
* [[#Step_3:_Log_into_Your_VPS|4 Step 3: Log into Your VPS]]&lt;br /&gt;
* [[#Step_4:_Update_the_Server|5 Step 4: Update the Server]]&lt;br /&gt;
* [[#Step_5:_Install_Docker|6 Step 5: Install Docker]]&lt;br /&gt;
* [[#Step_6:_Install_Git|7 Step 6: Install Git]]&lt;br /&gt;
* [[#Step_7:_Fork_and_Clone_the_Code|8 Step 7: Fork and Clone the Code]]&lt;br /&gt;
* [[#Step_8:_Configure_Your_Server_(.env_file)|9 Step 8: Configure Your Server (.env file)]]&lt;br /&gt;
* [[#Step_9:_Configure_Nginx_—_HTTP_Only_First|10 Step 9: Configure Nginx — HTTP Only First]]&lt;br /&gt;
* [[#Step_10:_Create_Data_Directories_and_Fix_Permissions|11 Step 10: Create Data Directories and Fix Permissions]]&lt;br /&gt;
* [[#Step_11:_Build_and_Start_the_Server|12 Step 11: Build and Start the Server]]&lt;br /&gt;
* [[#Step_12:_Get_Your_SSL_Certificate|13 Step 12: Get Your SSL Certificate]]&lt;br /&gt;
* [[#Step_13:_Enable_HTTPS_in_Nginx|14 Step 13: Enable HTTPS in Nginx]]&lt;br /&gt;
* [[#Step_14:_Set_Up_SSL_Auto-Renewal|15 Step 14: Set Up SSL Auto-Renewal]]&lt;br /&gt;
* [[#Step_15:_Test_Everything_is_Working|16 Step 15: Test Everything is Working]]&lt;br /&gt;
* [[#Step_16:_Set_Up_Your_Call_Rates_on_Hive|17 Step 16: Set Up Your Call Rates on Hive]]&lt;br /&gt;
* [[#Feature_Guide:_What_Your_Server_Can_Do|18 Feature Guide: What Your Server Can Do]]&lt;br /&gt;
* [[#Admin_Configuration_Reference|19 Admin Configuration Reference]]&lt;br /&gt;
* [[#Updating_Your_Server|20 Updating Your Server]]&lt;br /&gt;
* [[#Common_Problems_and_Fixes|21 Common Problems and Fixes]]&lt;br /&gt;
* [[#Quick_Reference|22 Quick Reference]]&lt;br /&gt;
&lt;br /&gt;
== What You Need ==&lt;br /&gt;
&lt;br /&gt;
Before starting, make sure you have the following:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;A Vultr account&#039;&#039;&#039; — sign up at [https://vultr.com vultr.com] - please use are [https://www.vultr.com/?ref=7704739 Vultr Referral link] to help us cover server costs.&lt;br /&gt;
* &#039;&#039;&#039;A domain name&#039;&#039;&#039; with DNS access — e.g. &amp;lt;code&amp;gt;call.yourdomain.com&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;Two Hive accounts&#039;&#039;&#039; — one for your server identity (receives platform fees), one for escrow (holds caller funds during calls). Create free accounts at [https://signup.hive.io signup.hive.io]&lt;br /&gt;
* &#039;&#039;&#039;Hive Keychain browser extension&#039;&#039;&#039; — for login and payments. Install from [https://hive-keychain.com hive-keychain.com]&lt;br /&gt;
* &#039;&#039;&#039;A GitHub account&#039;&#039;&#039; — free at [https://github.com github.com]. You will fork the v4call project.&lt;br /&gt;
* &#039;&#039;&#039;A terminal&#039;&#039;&#039; — Mac: Terminal app. Windows: PowerShell or PuTTY.&lt;br /&gt;
&lt;br /&gt;
You do not need to know how to code. Every command can be copy-pasted exactly as shown.&lt;br /&gt;
&lt;br /&gt;
== Step 1: Create Your Vultr VPS ==&lt;br /&gt;
&lt;br /&gt;
# Log into [https://my.vultr.com my.vultr.com]&lt;br /&gt;
# Click &#039;&#039;&#039;Deploy New Server&#039;&#039;&#039;&lt;br /&gt;
# Choose &#039;&#039;&#039;Cloud Compute — Shared CPU&#039;&#039;&#039;&lt;br /&gt;
# Choose a location close to you&lt;br /&gt;
# Choose &#039;&#039;&#039;Ubuntu 24.04 LTS x64&#039;&#039;&#039;&lt;br /&gt;
# Choose the &#039;&#039;&#039;$6/month&#039;&#039;&#039; plan (1 CPU, 1GB RAM)&lt;br /&gt;
# Set Server Hostname to something like &amp;lt;code&amp;gt;v4call-server&amp;lt;/code&amp;gt;&lt;br /&gt;
# Click &#039;&#039;&#039;Deploy Now&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Wait ~60 seconds for it to start. Click the server to find:&lt;br /&gt;
* &#039;&#039;&#039;IP Address&#039;&#039;&#039; — looks like &amp;lt;code&amp;gt;123.456.789.012&amp;lt;/code&amp;gt; — write it down&lt;br /&gt;
* &#039;&#039;&#039;Password&#039;&#039;&#039; — click the eye icon — write it down&lt;br /&gt;
&lt;br /&gt;
== Step 2: Point Your Domain at the VPS ==&lt;br /&gt;
&lt;br /&gt;
Log into your DNS provider and add an A record:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Field !! Value&lt;br /&gt;
|-&lt;br /&gt;
| Type || A&lt;br /&gt;
|-&lt;br /&gt;
| Name || &amp;lt;code&amp;gt;call&amp;lt;/code&amp;gt; (or &amp;lt;code&amp;gt;@&amp;lt;/code&amp;gt; for root domain)&lt;br /&gt;
|-&lt;br /&gt;
| Value || Your VPS IP address&lt;br /&gt;
|-&lt;br /&gt;
| TTL || 300&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
DNS takes a few minutes to propagate. Check from your computer later:&lt;br /&gt;
&lt;br /&gt;
 nslookup call.yourdomain.com&lt;br /&gt;
&lt;br /&gt;
Must show your VPS IP before Step 12. You can continue with all other steps while waiting.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Log into Your VPS ==&lt;br /&gt;
&lt;br /&gt;
Open a terminal on your computer:&lt;br /&gt;
&lt;br /&gt;
 ssh root@YOUR_SERVER_IP&lt;br /&gt;
&lt;br /&gt;
Type &amp;lt;code&amp;gt;yes&amp;lt;/code&amp;gt; when asked about the fingerprint, then paste the password from Vultr (right-click to paste in most terminals).&lt;br /&gt;
&lt;br /&gt;
When you see &amp;lt;code&amp;gt;root@v4call-server:~#&amp;lt;/code&amp;gt; you are in.&lt;br /&gt;
&lt;br /&gt;
== Step 4: Update the Server ==&lt;br /&gt;
&lt;br /&gt;
 apt update &amp;amp;&amp;amp; apt upgrade -y&lt;br /&gt;
&lt;br /&gt;
Wait for it to complete (1-2 minutes).&lt;br /&gt;
&lt;br /&gt;
== Step 5: Install Docker ==&lt;br /&gt;
&lt;br /&gt;
Install Docker using the official installer script:&lt;br /&gt;
&lt;br /&gt;
 curl -fsSL https://get.docker.com | sh&lt;br /&gt;
&lt;br /&gt;
Verify:&lt;br /&gt;
&lt;br /&gt;
 docker --version&lt;br /&gt;
&lt;br /&gt;
Should show &amp;lt;code&amp;gt;Docker version 26.x.x&amp;lt;/code&amp;gt; or similar.&lt;br /&gt;
&lt;br /&gt;
Install Docker Compose plugin:&lt;br /&gt;
&lt;br /&gt;
 apt install -y docker-compose-plugin&lt;br /&gt;
&lt;br /&gt;
Verify:&lt;br /&gt;
&lt;br /&gt;
 docker compose version&lt;br /&gt;
&lt;br /&gt;
Should show &amp;lt;code&amp;gt;Docker Compose version v2.x.x&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Step 6: Install Git ==&lt;br /&gt;
&lt;br /&gt;
 apt install -y git&lt;br /&gt;
 git --version&lt;br /&gt;
&lt;br /&gt;
== Step 7: Download and/or Fork and Clone the Code ==&lt;br /&gt;
&lt;br /&gt;
=== Clone onto your VPS ===&lt;br /&gt;
&lt;br /&gt;
 cd /opt&lt;br /&gt;
 git clone https://github.com/CompleteNoobs/v4call.git&lt;br /&gt;
 cd v4call&lt;br /&gt;
&lt;br /&gt;
==== Fork on GitHub - Optional ====&lt;br /&gt;
&lt;br /&gt;
Forking gives you your own copy of the code that you can customise — change the name, branding, fees — without affecting the original project.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://github.com/CompleteNoobs/v4call https://github.com/CompleteNoobs/v4call]&lt;br /&gt;
# Click the &#039;&#039;&#039;Fork&#039;&#039;&#039; button (top right of the page)&lt;br /&gt;
# Select your GitHub account as the destination&lt;br /&gt;
# Click &#039;&#039;&#039;Create fork&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
You now have your own copy at &amp;lt;code&amp;gt;https://github.com/YOURUSERNAME/v4call&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Clone onto your VPS ====&lt;br /&gt;
&lt;br /&gt;
 cd /opt&lt;br /&gt;
 git clone https://github.com/YOURUSERNAME/v4call.git&lt;br /&gt;
 cd v4call&lt;br /&gt;
&lt;br /&gt;
List the files to confirm:&lt;br /&gt;
&lt;br /&gt;
 ls&lt;br /&gt;
&lt;br /&gt;
You should see: &amp;lt;code&amp;gt;server.js  public/  Dockerfile  docker-compose.yml  nginx/  package.json  .env.example&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 8: Configure Your Server (.env file) ==&lt;br /&gt;
&lt;br /&gt;
All settings live in a single &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; file. Copy the template:&lt;br /&gt;
&lt;br /&gt;
 cp .env.example .env&lt;br /&gt;
 nano .env&lt;br /&gt;
&lt;br /&gt;
Fill in your values:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# ── Server Identity ──────────────────────────────────────&lt;br /&gt;
SERVER_NAME=yourcallapp&lt;br /&gt;
SERVER_DOMAIN=call.yourdomain.com&lt;br /&gt;
SERVER_HIVE_ACCOUNT=yourhiveaccount&lt;br /&gt;
ESCROW_ACCOUNT=yourescrowaccount&lt;br /&gt;
V4CALL_ESCROW_KEY=5Kxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&lt;br /&gt;
# Secret key to access /admin/ledger and /admin/balance endpoints.&lt;br /&gt;
# Choose a long random string — treat it like a password.&lt;br /&gt;
# Example: openssl rand -hex 32&lt;br /&gt;
ADMIN_KEY=make-up-a-long-random-string-at-least-20-characters&lt;br /&gt;
&lt;br /&gt;
# ── Hive Blockchain ──────────────────────────────────────&lt;br /&gt;
CHAIN=hive&lt;br /&gt;
HIVE_API=&lt;br /&gt;
&lt;br /&gt;
# ── Platform Fee ─────────────────────────────────────────&lt;br /&gt;
# Percentage your server takes from each paid call/DM (10 = 10%)&lt;br /&gt;
# This is the MINIMUM fee — users whose rates post sets a lower&lt;br /&gt;
# platform fee will be rejected. Users who set a higher fee&lt;br /&gt;
# get the best price (your server&#039;s rate, not their higher number).&lt;br /&gt;
DEFAULT_PLATFORM_FEE=10&lt;br /&gt;
&lt;br /&gt;
# ── Network ──────────────────────────────────────────────&lt;br /&gt;
PORT=3000&lt;br /&gt;
BIND_HOST=127.0.0.1&lt;br /&gt;
&lt;br /&gt;
# ── Chat Storage ─────────────────────────────────────────&lt;br /&gt;
# How many days to keep stored DMs before automatic cleanup&lt;br /&gt;
DM_RETENTION_DAYS=33&lt;br /&gt;
# How many days to keep stored room messages before cleanup&lt;br /&gt;
ROOM_RETENTION_DAYS=33&lt;br /&gt;
# How many recent DMs per conversation to show on login (0 = off)&lt;br /&gt;
DM_PREVIEW_COUNT=1&lt;br /&gt;
&lt;br /&gt;
# ── Call Behaviour (advanced — defaults are fine) ────────&lt;br /&gt;
# CALL_COOLDOWN_MS=30000&lt;br /&gt;
# MAX_CALL_DURATION_MIN=120&lt;br /&gt;
# PAYMENT_VERIFY_RETRIES=3&lt;br /&gt;
# PAYMENT_VERIFY_DELAY_MS=5000&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Key points:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;V4CALL_ESCROW_KEY&amp;lt;/code&amp;gt; — must be the &#039;&#039;&#039;active&#039;&#039;&#039; private key for your escrow account. Find it in your Hive wallet → Keys &amp;amp; Permissions → Active. Starts with &amp;lt;code&amp;gt;5K&amp;lt;/code&amp;gt;. &#039;&#039;&#039;Never share this.&#039;&#039;&#039;&lt;br /&gt;
* &amp;lt;code&amp;gt;ADMIN_KEY&amp;lt;/code&amp;gt; — invent a secret password for accessing admin tools&lt;br /&gt;
* &amp;lt;code&amp;gt;HIVE_API&amp;lt;/code&amp;gt; — leave blank to use all built-in Hive nodes automatically&lt;br /&gt;
* &amp;lt;code&amp;gt;DEFAULT_PLATFORM_FEE&amp;lt;/code&amp;gt; — your server&#039;s minimum platform fee percentage. See [[#Platform_Fee_System|Platform Fee System]] below for how this works.&lt;br /&gt;
* &amp;lt;code&amp;gt;DM_RETENTION_DAYS&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;ROOM_RETENTION_DAYS&amp;lt;/code&amp;gt; — how long chat messages are kept in the database. A cleanup job runs every hour and deletes anything older. Set to &amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; to keep messages indefinitely (not recommended).&lt;br /&gt;
* &amp;lt;code&amp;gt;DM_PREVIEW_COUNT&amp;lt;/code&amp;gt; — when a user logs in, this many recent DMs per conversation are loaded into the lobby chat so they can see previews. Set to &amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; to disable previews (users still get an unread count alert).&lt;br /&gt;
&lt;br /&gt;
Save: &#039;&#039;&#039;Ctrl+X&#039;&#039;&#039; → &#039;&#039;&#039;Y&#039;&#039;&#039; → &#039;&#039;&#039;Enter&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Step 9: Configure Nginx — HTTP Only First ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;This step is critical.&#039;&#039;&#039; A very common mistake is putting the HTTPS/SSL config in Nginx before getting the certificate. Nginx tries to load the certificate at startup — if the file doesn&#039;t exist yet, Nginx crashes in a restart loop. Certbot then cannot serve the challenge because Nginx is down. Result: no certificate, stuck in a loop.&lt;br /&gt;
&lt;br /&gt;
The fix: always start with HTTP only, get the certificate, then add HTTPS.&lt;br /&gt;
&lt;br /&gt;
Edit the Nginx config:&lt;br /&gt;
&lt;br /&gt;
 nano /opt/v4call/nginx/v4call.conf&lt;br /&gt;
&lt;br /&gt;
Delete everything and replace with this HTTP-only config:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
server {&lt;br /&gt;
    listen 80;&lt;br /&gt;
    server_name call.yourdomain.com www.call.yourdomain.com;&lt;br /&gt;
&lt;br /&gt;
    # Certbot challenge path — do not remove this block&lt;br /&gt;
    location /.well-known/acme-challenge/ {&lt;br /&gt;
        root /var/www/certbot;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    # Proxy all other requests to the v4call app&lt;br /&gt;
    location / {&lt;br /&gt;
        proxy_pass         http://app:3000;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header   Upgrade $http_upgrade;&lt;br /&gt;
        proxy_set_header   Connection &amp;quot;upgrade&amp;quot;;&lt;br /&gt;
        proxy_set_header   Host $host;&lt;br /&gt;
        proxy_set_header   X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header   X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_read_timeout 86400;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Replace &amp;lt;code&amp;gt;call.yourdomain.com&amp;lt;/code&amp;gt; with your actual domain in both places.&lt;br /&gt;
&lt;br /&gt;
Save: &#039;&#039;&#039;Ctrl+X&#039;&#039;&#039; → &#039;&#039;&#039;Y&#039;&#039;&#039; → &#039;&#039;&#039;Enter&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Step 10: Create Data Directories and Fix Permissions ==&lt;br /&gt;
&lt;br /&gt;
Create the folders Docker uses for persistent data:&lt;br /&gt;
&lt;br /&gt;
 mkdir -p /opt/v4call/data/logs  \&lt;br /&gt;
 mkdir -p /opt/v4call/data/certbot/conf  \&lt;br /&gt;
 mkdir -p /opt/v4call/data/certbot/www/.well-known/acme-challenge  \&lt;br /&gt;
 mkdir -p /opt/v4call/data/certbot/logs&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix permissions — do not skip this.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
The v4call app runs inside Docker as user &amp;lt;code&amp;gt;node&amp;lt;/code&amp;gt; (UID 1000). On the host, these directories are created by root, so the container cannot write to them. This causes a &amp;lt;code&amp;gt;SQLITE_CANTOPEN&amp;lt;/code&amp;gt; error that crashes the app.&lt;br /&gt;
&lt;br /&gt;
Fix the logs directory for the app:&lt;br /&gt;
&lt;br /&gt;
 chown -R 1000:1000 /opt/v4call/data/logs&lt;br /&gt;
&lt;br /&gt;
Certbot runs as root so its directories stay root-owned:&lt;br /&gt;
&lt;br /&gt;
 chown -R root:root /opt/v4call/data/certbot&lt;br /&gt;
 chmod -R 755 /opt/v4call/data/certbot&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Note:&#039;&#039;&#039; The app creates two separate SQLite databases in the logs directory:&lt;br /&gt;
* &amp;lt;code&amp;gt;v4call-ledger.db&amp;lt;/code&amp;gt; — payment records (calls, ring fees, payouts). Only the server writes to this.&lt;br /&gt;
* &amp;lt;code&amp;gt;v4call-chat.db&amp;lt;/code&amp;gt; — stored DMs and room messages. Separate from the ledger for security — if a bug in chat storage were exploited, the payment ledger remains untouched.&lt;br /&gt;
&lt;br /&gt;
== Step 11: Build and Start the Server ==&lt;br /&gt;
&lt;br /&gt;
 cd /opt/v4call&lt;br /&gt;
 docker compose up -d --build&lt;br /&gt;
&lt;br /&gt;
The first build downloads dependencies and takes 2-4 minutes. Check the status:&lt;br /&gt;
&lt;br /&gt;
 docker compose ps&lt;br /&gt;
&lt;br /&gt;
You should see:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
NAME              STATUS&lt;br /&gt;
v4call-app        Up (healthy)&lt;br /&gt;
v4call-nginx      Up&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check the app started correctly:&lt;br /&gt;
&lt;br /&gt;
 docker compose logs app&lt;br /&gt;
&lt;br /&gt;
Look for:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
[ledger] SQLite ready: /app/logs/v4call-ledger.db&lt;br /&gt;
[chat] SQLite ready: /app/logs/v4call-chat.db&lt;br /&gt;
v4call server running on 0.0.0.0:3000&lt;br /&gt;
[config] Server: yourcallapp (call.yourdomain.com)&lt;br /&gt;
[config] DM retention: 33 days | Room retention: 33 days | DM preview: 1&lt;br /&gt;
✓ Escrow key verified — matches @yourescrowaccount active key&lt;br /&gt;
✓ Escrow account @yourescrowaccount balance: 0.000 HBD&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If you see &amp;lt;code&amp;gt;SqliteError: unable to open database file&amp;lt;/code&amp;gt; — run the chown command from Step 10 again then &amp;lt;code&amp;gt;docker compose restart app&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Test HTTP is working:&lt;br /&gt;
&lt;br /&gt;
 curl http://call.yourdomain.com/debug-state&lt;br /&gt;
&lt;br /&gt;
Should return: &amp;lt;code&amp;gt;{&amp;quot;lobbyUsers&amp;quot;:[],&amp;quot;rooms&amp;quot;:[]}&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If you can see the site in a browser over HTTP at this point — everything is working and ready for the certificate.&lt;br /&gt;
&lt;br /&gt;
== Step 12: Get Your SSL Certificate ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Before running certbot&#039;&#039;&#039;, verify DNS and the challenge path are both working:&lt;br /&gt;
&lt;br /&gt;
 # Check DNS points to this server&lt;br /&gt;
 nslookup call.yourdomain.com&lt;br /&gt;
&lt;br /&gt;
 # Test the challenge path&lt;br /&gt;
 echo &amp;quot;test&amp;quot; &amp;gt; /opt/v4call/data/certbot/www/.well-known/acme-challenge/testfile&lt;br /&gt;
 curl http://call.yourdomain.com/.well-known/acme-challenge/testfile&lt;br /&gt;
&lt;br /&gt;
The curl command must return &amp;lt;code&amp;gt;test&amp;lt;/code&amp;gt;. If it does not, Nginx is not running — check &amp;lt;code&amp;gt;docker compose ps&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;docker compose logs nginx&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
When both work, get the certificate. The &amp;lt;code&amp;gt;--entrypoint certbot&amp;lt;/code&amp;gt; flag is required — without it, Docker runs the container&#039;s default renewal loop instead of the certonly command:&lt;br /&gt;
&lt;br /&gt;
 docker compose run --rm \&lt;br /&gt;
   --entrypoint certbot \&lt;br /&gt;
   certbot certonly \&lt;br /&gt;
   --webroot \&lt;br /&gt;
   -w /var/www/certbot \&lt;br /&gt;
   -d call.yourdomain.com \&lt;br /&gt;
   --email your@email.com \&lt;br /&gt;
   --agree-tos \&lt;br /&gt;
   --no-eff-email&lt;br /&gt;
&lt;br /&gt;
Replace &amp;lt;code&amp;gt;call.yourdomain.com&amp;lt;/code&amp;gt; with your domain and &amp;lt;code&amp;gt;your@email.com&amp;lt;/code&amp;gt; with your email.&lt;br /&gt;
&lt;br /&gt;
Success looks like:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
Requesting a certificate for call.yourdomain.com&lt;br /&gt;
&lt;br /&gt;
Successfully received certificate.&lt;br /&gt;
Certificate is saved at: /etc/letsencrypt/live/call.yourdomain.com/fullchain.pem&lt;br /&gt;
Key is saved at:         /etc/letsencrypt/live/call.yourdomain.com/privkey.pem&lt;br /&gt;
This certificate expires on 2026-07-09.&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Verify the files exist on the host:&lt;br /&gt;
&lt;br /&gt;
 ls /opt/v4call/data/certbot/conf/live/call.yourdomain.com/&lt;br /&gt;
&lt;br /&gt;
Should show: &amp;lt;code&amp;gt;cert.pem  chain.pem  fullchain.pem  privkey.pem  README&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 13: Enable HTTPS in Nginx ==&lt;br /&gt;
&lt;br /&gt;
Now the certificate exists, update Nginx to serve HTTPS.&lt;br /&gt;
&lt;br /&gt;
 nano /opt/v4call/nginx/v4call.conf&lt;br /&gt;
&lt;br /&gt;
Replace everything with the full HTTPS config:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
# HTTP — only used for certbot challenge and redirect to HTTPS&lt;br /&gt;
server {&lt;br /&gt;
    listen 80;&lt;br /&gt;
    server_name v4call.com v4call.com;&lt;br /&gt;
&lt;br /&gt;
    location /.well-known/acme-challenge/ {&lt;br /&gt;
        root /var/www/certbot;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    location / {&lt;br /&gt;
        return 301 https://$host$request_uri;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# HTTPS — auth lives here&lt;br /&gt;
server {&lt;br /&gt;
    listen 443 ssl;&lt;br /&gt;
    server_name v4call.com v4call.com;&lt;br /&gt;
# CHANGE PATH: for ssl certifcates change v4call.com to your path: which you can find at &amp;quot;ls data/certbot/conf/live/&amp;quot;&lt;br /&gt;
    ssl_certificate     /etc/letsencrypt/live/v4call.com/fullchain.pem;&lt;br /&gt;
    ssl_certificate_key /etc/letsencrypt/live/v4call.com/privkey.pem;&lt;br /&gt;
&lt;br /&gt;
    ssl_protocols TLSv1.2 TLSv1.3;&lt;br /&gt;
    ssl_prefer_server_ciphers off;&lt;br /&gt;
&lt;br /&gt;
    add_header Strict-Transport-Security &amp;quot;max-age=63072000&amp;quot; always;&lt;br /&gt;
    add_header X-Frame-Options DENY;&lt;br /&gt;
    add_header X-Content-Type-Options nosniff;&lt;br /&gt;
&lt;br /&gt;
    # When user cancels the login prompt, send them here&lt;br /&gt;
    error_page 401 /info.html;&lt;br /&gt;
&lt;br /&gt;
    # info.html is served directly by Nginx — no auth, no proxy&lt;br /&gt;
    location = /info.html {&lt;br /&gt;
        root /usr/share/nginx/html;&lt;br /&gt;
        auth_basic off;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    # WebSocket — requires auth&lt;br /&gt;
    location /socket.io/ {&lt;br /&gt;
# uncomment to require username and password to enter site - useful for testing&lt;br /&gt;
#        auth_basic           &amp;quot;v4call — Private Testing&amp;quot;;&lt;br /&gt;
#        auth_basic_user_file /etc/nginx/.htpasswd;&lt;br /&gt;
&lt;br /&gt;
        proxy_pass         http://app:3000;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header   Upgrade $http_upgrade;&lt;br /&gt;
        proxy_set_header   Connection &amp;quot;upgrade&amp;quot;;&lt;br /&gt;
        proxy_set_header   Host $host;&lt;br /&gt;
        proxy_set_header   X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header   X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_cache_bypass $http_upgrade;&lt;br /&gt;
        proxy_read_timeout 86400;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    # Everything else — requires auth, proxied to app&lt;br /&gt;
    location / {&lt;br /&gt;
# uncomment to require username and password to enter site - useful for testing&lt;br /&gt;
#        auth_basic           &amp;quot;v4call — Private Testing&amp;quot;;&lt;br /&gt;
#        auth_basic_user_file /etc/nginx/.htpasswd;&lt;br /&gt;
&lt;br /&gt;
        proxy_pass         http://app:3000;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header   Host $host;&lt;br /&gt;
        proxy_set_header   X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header   X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_read_timeout 300;&lt;br /&gt;
        proxy_send_timeout 300;&lt;br /&gt;
    }&lt;br /&gt;
    # This will log correct and incorrect attempts logging in, if .htpasswd used&lt;br /&gt;
    error_log /var/log/nginx/error.log warn;&lt;br /&gt;
    access_log /var/log/nginx/access.log;&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* REPLACE &amp;lt;code&amp;gt;call.yourdomain.com&amp;lt;/code&amp;gt; with your domain throughout (it appears 5 times).&lt;br /&gt;
* CHANGE PATH: for ssl certifcates change v4call.com to your path: which you can find at &amp;lt;code&amp;gt;ls data/certbot/conf/live/&amp;lt;/code&amp;gt; if not sure.&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
    ssl_certificate     /etc/letsencrypt/live/v4call.com/fullchain.pem;&lt;br /&gt;
    ssl_certificate_key /etc/letsencrypt/live/v4call.com/privkey.pem;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Save and restart Nginx:&lt;br /&gt;
&lt;br /&gt;
 docker compose restart nginx&lt;br /&gt;
&lt;br /&gt;
Check the logs — you should see &#039;&#039;&#039;no&#039;&#039;&#039; &amp;lt;code&amp;gt;[emerg]&amp;lt;/code&amp;gt; errors:&lt;br /&gt;
&lt;br /&gt;
 docker compose logs nginx&lt;br /&gt;
&lt;br /&gt;
The last lines should show:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
nginx/1.x.x ...&lt;br /&gt;
start worker processes&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
* If you see an error such as:&lt;br /&gt;
&amp;lt;pre&amp;gt;[emerg] 1#1: cannot load certificate &amp;quot;/etc/letsencrypt/live/call.yourdomain.com/fullchain.pem&amp;quot;: BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/etc/letsencrypt/live/call.yourdomain.com/fullchain.pem, r) error:10000080:BIO routines::no such file)&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Make sure you edited &amp;lt;code&amp;gt;nginx/v4call.conf&amp;lt;/code&amp;gt; correctly.&lt;br /&gt;
* To find the path for your SSL certs look in path &amp;lt;code&amp;gt;data/certbot/conf/live/&amp;lt;/code&amp;gt;&lt;br /&gt;
* There are 4 lines to check in &amp;lt;code&amp;gt;nginx/v4call.conf&amp;lt;/code&amp;gt;:&lt;br /&gt;
** Line 4: &amp;lt;code&amp;gt;server_name call.yourdomain.com www.call.yourdomain.com;&amp;lt;/code&amp;gt;&lt;br /&gt;
** Line 18: &amp;lt;code&amp;gt;server_name call.yourdomain.com www.call.yourdomain.com;&amp;lt;/code&amp;gt;&lt;br /&gt;
** Line 20: &amp;lt;code&amp;gt;ssl_certificate     /etc/letsencrypt/live/call.yourdomain.com/fullchain.pem;&amp;lt;/code&amp;gt;&lt;br /&gt;
** Line 21: &amp;lt;code&amp;gt;ssl_certificate_key /etc/letsencrypt/live/call.yourdomain.com/privkey.pem;&amp;lt;/code&amp;gt;&lt;br /&gt;
* When done restart nginx with: &amp;lt;code&amp;gt;docker compose restart nginx&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 14: Set Up SSL Auto-Renewal ==&lt;br /&gt;
&lt;br /&gt;
Let&#039;s Encrypt certificates expire after 90 days. A cron job renews them automatically.&lt;br /&gt;
&lt;br /&gt;
 crontab -e&lt;br /&gt;
&lt;br /&gt;
Add this line at the bottom:&lt;br /&gt;
&lt;br /&gt;
 0 3 * * * cd /opt/v4call &amp;amp;&amp;amp; docker compose run --rm --entrypoint certbot certbot renew --quiet &amp;amp;&amp;amp; docker compose exec nginx nginx -s reload&lt;br /&gt;
&lt;br /&gt;
Save and exit. This runs at 3am every day, renews if the cert is close to expiry, and reloads Nginx to pick up the new certificate.&lt;br /&gt;
&lt;br /&gt;
== Step 15: Test Everything is Working ==&lt;br /&gt;
&lt;br /&gt;
Test HTTPS:&lt;br /&gt;
&lt;br /&gt;
 curl https://call.yourdomain.com/debug-state&lt;br /&gt;
&lt;br /&gt;
Should return: &amp;lt;code&amp;gt;{&amp;quot;lobbyUsers&amp;quot;:[],&amp;quot;rooms&amp;quot;:[]}&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Test admin access:&lt;br /&gt;
&lt;br /&gt;
 curl &amp;quot;https://call.yourdomain.com/admin/balance?key=YOUR_ADMIN_KEY&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Should return your escrow account balance.&lt;br /&gt;
&lt;br /&gt;
Open your browser and go to &amp;lt;code&amp;gt;https://call.yourdomain.com&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You should see the v4call login page with:&lt;br /&gt;
* A padlock icon in the browser address bar&lt;br /&gt;
* A green &#039;&#039;&#039;⚡ Sign in with Keychain&#039;&#039;&#039; button (if Hive Keychain is installed)&lt;br /&gt;
* A manual posting key login option below it&lt;br /&gt;
* A &#039;&#039;&#039;📖 New here? Learn the v4call basics →&#039;&#039;&#039; link at the bottom&lt;br /&gt;
&lt;br /&gt;
Log in with Hive Keychain or a posting key to confirm the login flow works. 🎉&lt;br /&gt;
&lt;br /&gt;
== Step 16: Set Up Your Call Rates on Hive ==&lt;br /&gt;
&lt;br /&gt;
For callers to be charged when they ring you, publish your rates on the Hive blockchain:&lt;br /&gt;
&lt;br /&gt;
# Make sure Hive Keychain is installed in your browser&lt;br /&gt;
# Go to &amp;lt;code&amp;gt;https://call.yourdomain.com/rate-editor.html&amp;lt;/code&amp;gt;&lt;br /&gt;
# Enter your Hive username&lt;br /&gt;
# Set your rates — ring fee, connect fee, duration rate per hour, minimum credit deposit&lt;br /&gt;
# Set &amp;lt;code&amp;gt;PLATFORM-FEE&amp;lt;/code&amp;gt; to at least your server&#039;s &amp;lt;code&amp;gt;DEFAULT_PLATFORM_FEE&amp;lt;/code&amp;gt; percentage (e.g. &amp;lt;code&amp;gt;10&amp;lt;/code&amp;gt; for 10%). If you set it lower, paid contacts to your account will be rejected on this server.&lt;br /&gt;
# Optionally add custom token sections (e.g. &amp;lt;code&amp;gt;[TOKEN:CNOOBS]&amp;lt;/code&amp;gt;) to offer discounted rates for callers who hold your token&lt;br /&gt;
# Click &#039;&#039;&#039;Generate&#039;&#039;&#039; to preview the rates block&lt;br /&gt;
# Click &#039;&#039;&#039;Post to Hive&#039;&#039;&#039; — Keychain will ask you to approve the post&lt;br /&gt;
&lt;br /&gt;
This creates a post titled &amp;lt;code&amp;gt;v4call-rates&amp;lt;/code&amp;gt; on your Hive blog. Your server reads this post automatically.&lt;br /&gt;
&lt;br /&gt;
To verify your server read the rates correctly:&lt;br /&gt;
&lt;br /&gt;
 https://call.yourdomain.com/debug-rates/yourusername&lt;br /&gt;
&lt;br /&gt;
To test with a specific caller (checks their token balances too):&lt;br /&gt;
&lt;br /&gt;
 https://call.yourdomain.com/debug-rates/yourusername?caller=theirusername&amp;amp;type=voice&lt;br /&gt;
&lt;br /&gt;
You should see your rates as JSON, including which currency and rates apply for that caller.&lt;br /&gt;
&lt;br /&gt;
== Feature Guide: What Your Server Can Do ==&lt;br /&gt;
&lt;br /&gt;
This section explains all the features available in v4call and how they work. No code changes needed — everything described here is built in and ready to use.&lt;br /&gt;
&lt;br /&gt;
=== Login Options ===&lt;br /&gt;
&lt;br /&gt;
v4call supports two ways to sign in:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Hive Keychain&#039;&#039;&#039; (recommended) — click the green &#039;&#039;&#039;⚡ Sign in with Keychain&#039;&#039;&#039; button. Keychain signs a challenge to prove your identity. No key paste needed. After login, a 🔑 panel appears in the lobby where you can optionally enter your posting key to unlock encrypted messaging (Keychain cannot expose private keys, so encryption needs the key entered once per session).&lt;br /&gt;
* &#039;&#039;&#039;Manual posting key&#039;&#039;&#039; — paste your Hive posting private key (starts with &amp;lt;code&amp;gt;5J&amp;lt;/code&amp;gt;) directly. The key stays in browser session memory only — never sent to the server.&lt;br /&gt;
&lt;br /&gt;
Both methods verify your identity against the Hive blockchain.&lt;br /&gt;
&lt;br /&gt;
=== Voice and Video Calls ===&lt;br /&gt;
&lt;br /&gt;
Each online user in the lobby shows three action buttons:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;📞 Green phone icon&#039;&#039;&#039; — start a &#039;&#039;&#039;voice-only&#039;&#039;&#039; call (audio, no camera)&lt;br /&gt;
* &#039;&#039;&#039;🎥 Blue camera icon&#039;&#039;&#039; — start a &#039;&#039;&#039;video&#039;&#039;&#039; call (audio + camera)&lt;br /&gt;
* &#039;&#039;&#039;💬 Purple chat bubble icon&#039;&#039;&#039; — open the &#039;&#039;&#039;DM panel&#039;&#039;&#039; to send a direct message&lt;br /&gt;
&lt;br /&gt;
Voice calls request microphone only — no camera permission prompt. Video calls request both. The caller and callee can have different call types — the type is set by whoever initiates the call.&lt;br /&gt;
&lt;br /&gt;
Separate rates can be set for voice and video calls in the rates post (voice is typically cheaper).&lt;br /&gt;
&lt;br /&gt;
=== Direct Messages (DMs) ===&lt;br /&gt;
&lt;br /&gt;
Click the purple 💬 button next to any online user to open the DM panel. DMs are end-to-end encrypted using Hive posting keys — the server stores only ciphertext it cannot read.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Chat storage:&#039;&#039;&#039; DMs are stored on the server in an encrypted database (&amp;lt;code&amp;gt;v4call-chat.db&amp;lt;/code&amp;gt;) for up to &amp;lt;code&amp;gt;DM_RETENTION_DAYS&amp;lt;/code&amp;gt; (default: 33 days). Both sender and recipient get their own encrypted copy stored, so both can retrieve their history later.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Unread alerts:&#039;&#039;&#039; When you log in, if you have unread DMs, a popup appears showing how many messages from how many users. Click a username in the popup to open their DM history.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;DM previews:&#039;&#039;&#039; The last &amp;lt;code&amp;gt;DM_PREVIEW_COUNT&amp;lt;/code&amp;gt; messages per conversation are loaded into the lobby chat on login, so you can see recent activity at a glance. Set to &amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; to disable.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Full history:&#039;&#039;&#039; Click the DM button for any user to load the complete conversation history, shown between &amp;quot;— DM history —&amp;quot; dividers.&lt;br /&gt;
&lt;br /&gt;
=== Rooms ===&lt;br /&gt;
&lt;br /&gt;
Users can create private rooms by selecting users in the lobby (toggle switch) and clicking &#039;&#039;&#039;Create &amp;amp; Invite&#039;&#039;&#039;. Rooms support encrypted messaging, video, and voice.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Room history:&#039;&#039;&#039; When a new user joins a room, they see past messages — broadcasts in full, encrypted messages only if they were addressed to them. A &amp;quot;— earlier messages —&amp;quot; divider separates history from live messages.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Ephemeral rooms:&#039;&#039;&#039; A warning banner at the top of every room says: &amp;quot;⚠ Room is ephemeral — if all users leave, the room and its history are deleted. New members can only read messages encrypted to their key.&amp;quot; When the last person leaves a room, all stored messages for that room are deleted from the database.&lt;br /&gt;
&lt;br /&gt;
=== Custom Token Payments (Hive-Engine) ===&lt;br /&gt;
&lt;br /&gt;
v4call supports payment in any Hive-Engine token, not just HBD. This is configured per-user in their rates post using &amp;lt;code&amp;gt;[TOKEN:SYMBOL]&amp;lt;/code&amp;gt; sections.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;How it works:&#039;&#039;&#039;&lt;br /&gt;
# A user (e.g. @cnoobz) creates a custom token on Hive-Engine (e.g. CNOOBS)&lt;br /&gt;
# In their rates post, they add a &amp;lt;code&amp;gt;[TOKEN:CNOOBS]&amp;lt;/code&amp;gt; section with lower rates than their default HBD rates&lt;br /&gt;
# When a caller who holds CNOOBS contacts @cnoobz, the server detects the token balance and offers the token rates&lt;br /&gt;
# If the caller holds multiple qualifying tokens, &#039;&#039;&#039;all options are shown&#039;&#039;&#039; in a currency picker — the caller chooses which to pay with&lt;br /&gt;
# The payment goes through Hive Keychain as a &amp;lt;code&amp;gt;custom_json&amp;lt;/code&amp;gt; Hive-Engine transfer&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;For the escrow account:&#039;&#039;&#039; Your escrow account needs to hold some of each custom token that users on your server accept. Token payouts (to the callee) and refunds (to the caller) are sent from the escrow account&#039;s token balance, just like HBD.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Payment picker:&#039;&#039;&#039; When multiple payment options exist (e.g. CNOOBS at 1 per message, HBD at 100000 per message), the payment modal shows clickable currency buttons so the caller can see all rates and choose the best option.&lt;br /&gt;
&lt;br /&gt;
=== Platform Fee System ===&lt;br /&gt;
&lt;br /&gt;
The platform fee is how your server earns revenue from paid calls and DMs.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;How it works:&#039;&#039;&#039;&lt;br /&gt;
* Your server&#039;s &amp;lt;code&amp;gt;DEFAULT_PLATFORM_FEE&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; is the &#039;&#039;&#039;minimum&#039;&#039;&#039; percentage your server accepts (e.g. &amp;lt;code&amp;gt;10&amp;lt;/code&amp;gt; = 10%)&lt;br /&gt;
* Each user sets &amp;lt;code&amp;gt;PLATFORM-FEE&amp;lt;/code&amp;gt; in their Hive rates post — this is the maximum fee they are willing to pay to a server&lt;br /&gt;
* If the user&#039;s posted fee is &#039;&#039;&#039;lower&#039;&#039;&#039; than your server&#039;s minimum → &#039;&#039;&#039;rejected&#039;&#039;&#039;. The caller sees a message explaining the mismatch, and the callee is told to raise their fee.&lt;br /&gt;
* If the user&#039;s posted fee &#039;&#039;&#039;meets or exceeds&#039;&#039;&#039; your server&#039;s minimum → &#039;&#039;&#039;accepted&#039;&#039;&#039;, and &#039;&#039;&#039;the server charges its own rate&#039;&#039;&#039; (the minimum), not the user&#039;s higher number. The callee gets the best price.&lt;br /&gt;
* If the user has &#039;&#039;&#039;no &amp;lt;code&amp;gt;PLATFORM-FEE&amp;lt;/code&amp;gt; line&#039;&#039;&#039; in their rates post → the server&#039;s default is used automatically. No mismatch.&lt;br /&gt;
* &#039;&#039;&#039;Free contacts&#039;&#039;&#039; (no payment involved) are never affected by fee enforcement.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why this matters for federation:&#039;&#039;&#039; Different servers can set different platform fees. Users can shop around — pick a server with a fee they find agreeable. This creates a free market for server operators.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Example:&#039;&#039;&#039;&lt;br /&gt;
* Your server: &amp;lt;code&amp;gt;DEFAULT_PLATFORM_FEE=3&amp;lt;/code&amp;gt; (3%)&lt;br /&gt;
* @alice posts: &amp;lt;code&amp;gt;PLATFORM-FEE: 5%&amp;lt;/code&amp;gt; → accepted, server charges 3% (best price for alice)&lt;br /&gt;
* @bob posts: &amp;lt;code&amp;gt;PLATFORM-FEE: 1%&amp;lt;/code&amp;gt; → rejected, bob needs to raise to at least 3%&lt;br /&gt;
* @charlie has no fee line → defaults to 3%, automatically accepted&lt;br /&gt;
&lt;br /&gt;
== Admin Configuration Reference ==&lt;br /&gt;
&lt;br /&gt;
All settings are in the &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; file. After changing any value, rebuild:&lt;br /&gt;
&lt;br /&gt;
 docker compose down &amp;amp;&amp;amp; docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Variable !! Default !! Description&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;SERVER_NAME&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;v4call&amp;lt;/code&amp;gt; || Display name for your server&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;SERVER_DOMAIN&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;v4call.com&amp;lt;/code&amp;gt; || Your server&#039;s domain&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;SERVER_HIVE_ACCOUNT&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;v4call&amp;lt;/code&amp;gt; || Hive account that receives platform fees&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ESCROW_ACCOUNT&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;v4call-escrow&amp;lt;/code&amp;gt; || Hive account that holds funds during calls&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;V4CALL_ESCROW_KEY&amp;lt;/code&amp;gt; || &#039;&#039;(none)&#039;&#039; || Active private key for the escrow account. &#039;&#039;&#039;Required.&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ADMIN_KEY&amp;lt;/code&amp;gt; || &#039;&#039;(none)&#039;&#039; || Password for admin endpoints (&amp;lt;code&amp;gt;/admin/balance&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;/admin/ledger&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;DEFAULT_PLATFORM_FEE&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;10&amp;lt;/code&amp;gt; || Server&#039;s minimum platform fee percentage&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;DM_RETENTION_DAYS&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;33&amp;lt;/code&amp;gt; || Days to keep stored DMs before cleanup&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ROOM_RETENTION_DAYS&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;33&amp;lt;/code&amp;gt; || Days to keep stored room messages before cleanup&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;DM_PREVIEW_COUNT&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;1&amp;lt;/code&amp;gt; || Recent DMs per conversation shown on login (0 = off)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;HIVE_API&amp;lt;/code&amp;gt; || &#039;&#039;(blank)&#039;&#039; || Override primary Hive API node. Blank = auto-select from built-in list&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;MAX_CALL_DURATION_MIN&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;120&amp;lt;/code&amp;gt; || Maximum call length in minutes before auto-disconnect&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;CALL_COOLDOWN_MS&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;30000&amp;lt;/code&amp;gt; || Milliseconds between call attempts to same user&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;PAYMENT_VERIFY_RETRIES&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;3&amp;lt;/code&amp;gt; || Number of attempts to verify a blockchain payment&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;PAYMENT_VERIFY_DELAY_MS&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;5000&amp;lt;/code&amp;gt; || Delay between verification retry attempts&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Debug Endpoints ===&lt;br /&gt;
&lt;br /&gt;
These are useful for testing without making actual calls:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;/debug-state&amp;lt;/code&amp;gt; — shows current lobby users and active rooms (no auth required)&lt;br /&gt;
* &amp;lt;code&amp;gt;/debug-rates/USERNAME&amp;lt;/code&amp;gt; — shows parsed rates for a user&lt;br /&gt;
* &amp;lt;code&amp;gt;/debug-rates/USERNAME?caller=CALLER&amp;amp;type=voice&amp;lt;/code&amp;gt; — shows what rates a specific caller would receive (checks token balances too)&lt;br /&gt;
* &amp;lt;code&amp;gt;/admin/balance?key=YOUR_ADMIN_KEY&amp;lt;/code&amp;gt; — shows escrow account HBD balance&lt;br /&gt;
* &amp;lt;code&amp;gt;/admin/ledger?key=YOUR_ADMIN_KEY&amp;lt;/code&amp;gt; — shows recent payment records&lt;br /&gt;
&lt;br /&gt;
== Updating Your Server ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important:&#039;&#039;&#039; Always use &amp;lt;code&amp;gt;docker compose down&amp;lt;/code&amp;gt; before rebuilding. Without this step, Docker may reuse the old container even after a rebuild, and your changes will not take effect.&lt;br /&gt;
&lt;br /&gt;
 cd /opt/v4call&lt;br /&gt;
 docker compose down&lt;br /&gt;
 docker compose build --no-cache&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
&lt;br /&gt;
Your data, SQLite databases and &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; config are preserved — they live in &amp;lt;code&amp;gt;data/logs/&amp;lt;/code&amp;gt; which is a mounted volume.&lt;br /&gt;
&lt;br /&gt;
To pull updates from GitHub and deploy:&lt;br /&gt;
&lt;br /&gt;
 cd /opt/v4call&lt;br /&gt;
 git pull&lt;br /&gt;
 docker compose down&lt;br /&gt;
 docker compose build --no-cache&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
&lt;br /&gt;
To push your own customisations to GitHub:&lt;br /&gt;
&lt;br /&gt;
 # On your local computer after making changes:&lt;br /&gt;
 git add .&lt;br /&gt;
 git commit -m &amp;quot;describe what you changed&amp;quot;&lt;br /&gt;
 git push&lt;br /&gt;
&lt;br /&gt;
 # On the VPS:&lt;br /&gt;
 cd /opt/v4call&lt;br /&gt;
 git pull&lt;br /&gt;
 docker compose down&lt;br /&gt;
 docker compose build --no-cache&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
&lt;br /&gt;
== Common Problems and Fixes ==&lt;br /&gt;
&lt;br /&gt;
=== Changes not showing after rebuild ===&lt;br /&gt;
&lt;br /&gt;
If you edited &amp;lt;code&amp;gt;server.js&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;index.html&amp;lt;/code&amp;gt; but changes are not visible, you probably forgot to bring Docker down first. &amp;lt;code&amp;gt;docker compose restart&amp;lt;/code&amp;gt; and even &amp;lt;code&amp;gt;docker compose up -d --build&amp;lt;/code&amp;gt; can reuse old containers.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
 docker compose down&lt;br /&gt;
 docker compose build --no-cache&lt;br /&gt;
 docker compose up -d&lt;br /&gt;
&lt;br /&gt;
=== SqliteError: unable to open database file ===&lt;br /&gt;
&lt;br /&gt;
The app container runs as UID 1000 but the logs directory was created by root. Fix:&lt;br /&gt;
&lt;br /&gt;
 chown -R 1000:1000 /opt/v4call/data/logs&lt;br /&gt;
 docker compose restart app&lt;br /&gt;
&lt;br /&gt;
=== Certbot says &amp;quot;No renewals were attempted&amp;quot; ===&lt;br /&gt;
&lt;br /&gt;
The certbot container&#039;s default behaviour is a renewal loop. Your &amp;lt;code&amp;gt;certonly&amp;lt;/code&amp;gt; command is being ignored. Always use &amp;lt;code&amp;gt;--entrypoint certbot&amp;lt;/code&amp;gt; to override it:&lt;br /&gt;
&lt;br /&gt;
 docker compose run --rm --entrypoint certbot certbot certonly ...&lt;br /&gt;
&lt;br /&gt;
Without &amp;lt;code&amp;gt;--entrypoint certbot&amp;lt;/code&amp;gt; the container runs its renewal script instead of your command.&lt;br /&gt;
&lt;br /&gt;
=== Nginx crashes with &amp;quot;cannot load certificate: No such file&amp;quot; ===&lt;br /&gt;
&lt;br /&gt;
You added the HTTPS server block before getting the certificate. Nginx reads all server blocks at startup — if the cert file doesn&#039;t exist, the entire process fails.&lt;br /&gt;
&lt;br /&gt;
Fix: revert nginx config to HTTP-only (Step 9), restart Nginx, get the certificate (Step 12), then re-add HTTPS (Step 13).&lt;br /&gt;
&lt;br /&gt;
=== Webroot challenge test returns nothing ===&lt;br /&gt;
&lt;br /&gt;
Nginx is not running. Check:&lt;br /&gt;
&lt;br /&gt;
 docker compose ps&lt;br /&gt;
 docker compose logs nginx&lt;br /&gt;
&lt;br /&gt;
If Nginx is restarting — it has the HTTPS config with a missing cert file. Use the HTTP-only config.&lt;br /&gt;
&lt;br /&gt;
=== &amp;quot;Escrow key does NOT match&amp;quot; warning on startup ===&lt;br /&gt;
&lt;br /&gt;
The key in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; is the wrong type. You need the &#039;&#039;&#039;active&#039;&#039;&#039; private key, not the posting or owner key. Find it in your Hive wallet → Keys &amp;amp; Permissions → Active. It starts with &amp;lt;code&amp;gt;5K&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
=== Site unreachable on port 443 ===&lt;br /&gt;
&lt;br /&gt;
Check that the certificate was issued:&lt;br /&gt;
&lt;br /&gt;
 ls /opt/v4call/data/certbot/conf/live/&lt;br /&gt;
&lt;br /&gt;
Should show a folder with your domain name. If empty — go back to Step 12.&lt;br /&gt;
&lt;br /&gt;
=== npm ci error during Docker build ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;npm ci&amp;lt;/code&amp;gt; command requires a &amp;lt;code&amp;gt;package-lock.json&amp;lt;/code&amp;gt; file. The project uses &amp;lt;code&amp;gt;npm install&amp;lt;/code&amp;gt; instead. If you see this error, check your &amp;lt;code&amp;gt;Dockerfile&amp;lt;/code&amp;gt; — it should say &amp;lt;code&amp;gt;npm install&amp;lt;/code&amp;gt; not &amp;lt;code&amp;gt;npm ci&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
=== Custom token payments not working ===&lt;br /&gt;
&lt;br /&gt;
If token rates are detected but payments fail:&lt;br /&gt;
* Check the escrow account holds the token — send some tokens to your escrow account on Hive-Engine&lt;br /&gt;
* Check the token symbol matches exactly (case-sensitive) between the rates post and Hive-Engine&lt;br /&gt;
* Check the server logs: &amp;lt;code&amp;gt;docker compose logs app | grep -i &amp;quot;token\|cnoobs\|escrow-token&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
* The Hive-Engine API endpoint must be &amp;lt;code&amp;gt;https://api.hive-engine.com/rpc/contracts&amp;lt;/code&amp;gt; — this is built into the code&lt;br /&gt;
&lt;br /&gt;
=== [encrypted — unlock with 🔑 key panel to read] ===&lt;br /&gt;
&lt;br /&gt;
You logged in with Hive Keychain. Keychain does not expose private keys, so encrypted messages cannot be decrypted without your posting key. Enter your posting key in the 🔑 panel at the bottom of the online users list. The key stays in browser session memory only — it is needed once per session.&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Command !! What it does&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose ps&amp;lt;/code&amp;gt; || Show status of all containers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose logs app&amp;lt;/code&amp;gt; || Show app logs&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose logs nginx&amp;lt;/code&amp;gt; || Show Nginx logs&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose logs -f app&amp;lt;/code&amp;gt; || Watch live logs (Ctrl+C to stop)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose down&amp;lt;/code&amp;gt; || Stop everything (&#039;&#039;&#039;always do this before rebuilding&#039;&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose build --no-cache&amp;lt;/code&amp;gt; || Rebuild without using cached layers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose up -d&amp;lt;/code&amp;gt; || Start everything in background&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt; || Full rebuild cycle (use after any code change)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose restart nginx&amp;lt;/code&amp;gt; || Restart Nginx after config-only changes (no rebuild needed)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;chown -R 1000:1000 /opt/v4call/data/logs&amp;lt;/code&amp;gt; || Fix SQLite write permissions&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose run --rm --entrypoint certbot certbot certificates&amp;lt;/code&amp;gt; || List SSL certificates&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;curl http://yourdomain.com/.well-known/acme-challenge/testfile&amp;lt;/code&amp;gt; || Test certbot webroot works&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;docker compose logs app &amp;amp;#124; grep -i &amp;quot;token\|escrow&amp;quot;&amp;lt;/code&amp;gt; || Check token payment logs&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Optional: Password Protect Your Server During Testing ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;strong&amp;gt;Optional: Password Protect Your Server During Testing (HTTP Basic Auth)&amp;lt;/strong&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
During development and testing you may want to restrict access so only people you invite can use your server. This uses Nginx HTTP Basic Auth — a simple username and password prompt that appears before the v4call login screen.&lt;br /&gt;
&lt;br /&gt;
When a visitor cancels the login prompt they are shown a public &amp;lt;code&amp;gt;info.html&amp;lt;/code&amp;gt; page where they can read about the project and request access.&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 1 — Install the htpasswd tool&amp;lt;/b&amp;gt; &lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;apt install -y apache2-utils&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 2 — Create the password file and add your first user &amp;lt;/b&amp;gt; &lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;htpasswd -c /opt/v4call/nginx/.htpasswd yourusername&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You will be prompted to enter and confirm a password. The &amp;lt;code&amp;gt;-c&amp;lt;/code&amp;gt; flag creates the file. Do not use &amp;lt;code&amp;gt;-c&amp;lt;/code&amp;gt; again or it will overwrite the file and delete existing users.&lt;br /&gt;
&lt;br /&gt;
To add more users later:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;htpasswd /opt/v4call/nginx/.htpasswd anotherusername&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To remove a user:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;htpasswd -D /opt/v4call/nginx/.htpasswd username&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 3 — Create the public info page &amp;lt;/b&amp;gt; &lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;nano /opt/v4call/public/info.html&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Paste your HTML content — a page explaining the project and how to request access. This page is served publicly without a password so visitors who cancel the login prompt can still read it.&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 4 — Mount the files into the Nginx container &amp;lt;/b&amp;gt; &lt;br /&gt;
&lt;br /&gt;
Edit &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt; and add two lines to the nginx volumes section:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
  nginx:&lt;br /&gt;
    volumes:&lt;br /&gt;
      - ./nginx/v4call.conf:/etc/nginx/conf.d/default.conf:ro&lt;br /&gt;
      - ./nginx/.htpasswd:/etc/nginx/.htpasswd:ro          # add this&lt;br /&gt;
      - ./public/info.html:/usr/share/nginx/html/info.html:ro  # add this&lt;br /&gt;
      - ./data/certbot/conf:/etc/letsencrypt:ro&lt;br /&gt;
      - ./data/certbot/www:/var/www/certbot:ro&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 5 — Update your Nginx config &amp;lt;/b&amp;gt; &lt;br /&gt;
&lt;br /&gt;
Add auth to the &#039;&#039;&#039;HTTPS server block&#039;&#039;&#039; (not the HTTP block — users are redirected to HTTPS so auth must live there). Add these lines to both the &amp;lt;code&amp;gt;/socket.io/&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;/&amp;lt;/code&amp;gt; location blocks, and add the &amp;lt;code&amp;gt;error_page&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;info.html&amp;lt;/code&amp;gt; location blocks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
server {&lt;br /&gt;
    listen 443 ssl;&lt;br /&gt;
    server_name call.yourdomain.com www.call.yourdomain.com;&lt;br /&gt;
&lt;br /&gt;
    # ... ssl_certificate lines stay the same ...&lt;br /&gt;
&lt;br /&gt;
    # Send cancelled logins to the public info page&lt;br /&gt;
    error_page 401 /info.html;&lt;br /&gt;
&lt;br /&gt;
    # info.html is served by Nginx directly — no auth, no proxy&lt;br /&gt;
    location = /info.html {&lt;br /&gt;
        root /usr/share/nginx/html;&lt;br /&gt;
        auth_basic off;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    # WebSocket — requires auth&lt;br /&gt;
    location /socket.io/ {&lt;br /&gt;
        auth_basic           &amp;quot;v4call — Private Testing&amp;quot;;&lt;br /&gt;
        auth_basic_user_file /etc/nginx/.htpasswd;&lt;br /&gt;
        proxy_pass http://app:3000;&lt;br /&gt;
        # ... rest of proxy headers stay the same ...&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    # All other requests — requires auth&lt;br /&gt;
    location / {&lt;br /&gt;
        auth_basic           &amp;quot;v4call — Private Testing&amp;quot;;&lt;br /&gt;
        auth_basic_user_file /etc/nginx/.htpasswd;&lt;br /&gt;
        proxy_pass http://app:3000;&lt;br /&gt;
        # ... rest of proxy headers stay the same ...&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
* Note: if you want to log successful and unsuccessful login attempts, add:&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
    error_log /var/log/nginx/error.log warn;&lt;br /&gt;
    access_log /var/log/nginx/access.log;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 6 — Recreate the Nginx container to pick up the new volume mounts &amp;lt;/b&amp;gt; &lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important&#039;&#039;&#039;: &amp;lt;code&amp;gt;docker compose restart nginx&amp;lt;/code&amp;gt; is not enough — it reuses the old container and ignores new volume mounts. You must recreate it:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
After this, verify the files are mounted inside the container:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker compose exec nginx ls /usr/share/nginx/html/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You should see &amp;lt;code&amp;gt;info.html&amp;lt;/code&amp;gt; listed alongside &amp;lt;code&amp;gt;50x.html&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;index.html&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Step 7 — Test it works &amp;lt;/b&amp;gt; &lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;curl -o /dev/null -s -w &amp;quot;%{http_code}&amp;quot; https://call.yourdomain.com/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Should return &amp;lt;code&amp;gt;401&amp;lt;/code&amp;gt; (login required).&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;curl -o /dev/null -s -w &amp;quot;%{http_code}&amp;quot; https://call.yourdomain.com/info.html&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Should return &amp;lt;code&amp;gt;200&amp;lt;/code&amp;gt; (public, no auth needed).&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;curl -u youruser:yourpassword -o /dev/null -s -w &amp;quot;%{http_code}&amp;quot; https://call.yourdomain.com/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Should return &amp;lt;code&amp;gt;200&amp;lt;/code&amp;gt; (correct credentials accepted).&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Managing users without restarting &amp;lt;/b&amp;gt; &lt;br /&gt;
&lt;br /&gt;
After adding or removing users from the &amp;lt;code&amp;gt;.htpasswd&amp;lt;/code&amp;gt; file, reload Nginx config without downtime:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker compose exec nginx nginx -s reload&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
No restart needed — Nginx re-reads the &amp;lt;code&amp;gt;.htpasswd&amp;lt;/code&amp;gt; file on every request anyway.&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Keep .htpasswd out of GitHub &amp;lt;/b&amp;gt; &lt;br /&gt;
&lt;br /&gt;
The password file should never be committed to your repository. Add it to &amp;lt;code&amp;gt;.gitignore&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;echo &amp;quot;nginx/.htpasswd&amp;quot; &amp;gt;&amp;gt; /opt/v4call/.gitignore&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
*&amp;lt;b&amp;gt; Removing auth when you go public &amp;lt;/b&amp;gt; &lt;br /&gt;
&lt;br /&gt;
When you are ready to open your server to everyone, simply remove the &amp;lt;code&amp;gt;auth_basic&amp;lt;/code&amp;gt; lines from &amp;lt;code&amp;gt;nginx/v4call.conf&amp;lt;/code&amp;gt; and reload:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker compose exec nginx nginx -s reload&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
No other changes needed.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
&#039;&#039;&#039;How to check if anyone tried to log in (failed attempts) or successfully logged in with .htpasswd on Nginx in Docker&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important Tip:&#039;&#039;&#039; Always run these commands in the same folder where your &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt; file is located. If you are in the wrong directory the commands will not find your container and nothing will show up.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;&#039;&#039; Basic Commands &#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
; &#039;&#039;&#039;See everything live (good while testing)&#039;&#039;&#039;&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose logs -f nginx&amp;lt;/code&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
: &amp;lt;code&amp;gt;-f&amp;lt;/code&amp;gt; means &amp;quot;follow/live&amp;quot; – new log lines appear automatically. Remove &amp;lt;code&amp;gt;-f&amp;lt;/code&amp;gt; if you only want to read the current logs once and stop.&lt;br /&gt;
&lt;br /&gt;
; &#039;&#039;&#039;Best command to watch failed login attempts (people guessing passwords)&#039;&#039;&#039;&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose logs -f nginx | grep -E &amp;quot;mismatch|not found|401&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; &#039;&#039;&#039;See who successfully logged in&#039;&#039;&#039;&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose logs nginx | grep &amp;quot;remote_user&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
; &#039;&#039;&#039;Combined view – most useful for daily checking (failed + successful)&#039;&#039;&#039;&lt;br /&gt;
: &amp;lt;code&amp;gt;docker compose logs -f --tail=100 nginx | grep -E &amp;quot;(mismatch|not found|remote_user|401)&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;&#039;&#039; What the Logs Look Like &#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Failed attempt (wrong password):&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;2026/04/17 01:23:45 [error] ... user &amp;quot;admin&amp;quot;: password mismatch, client: 185.123.45.67, ...&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Failed attempt (wrong username):&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;2026/04/17 01:24:12 [error] ... user &amp;quot;hacker123&amp;quot; was not found in &amp;quot;/etc/nginx/.htpasswd&amp;quot;, client: 45.67.89.10, ...&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Successful login:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;pre&amp;gt;... &amp;quot;GET /protected/ HTTP/1.1&amp;quot; 200 ... remote_user: &amp;quot;myuser&amp;quot; ...&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;&#039;&#039; How to Customise These Commands &#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* Change &amp;lt;code&amp;gt;nginx&amp;lt;/code&amp;gt; to the exact name of your service if it is different in docker-compose.yml.&lt;br /&gt;
* Remove &amp;lt;code&amp;gt;-f&amp;lt;/code&amp;gt; to read the full log once without live following.&lt;br /&gt;
* Change &amp;lt;code&amp;gt;--tail=100&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;--tail=500&amp;lt;/code&amp;gt; (or any number) to show more or fewer old lines.&lt;br /&gt;
* Add or remove words in the &amp;lt;code&amp;gt;grep&amp;lt;/code&amp;gt; part to filter differently.  &lt;br /&gt;
  Examples:&lt;br /&gt;
  * Only failed attempts: &amp;lt;code&amp;gt;grep -E &amp;quot;mismatch|not found&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
  * Only 401 errors: &amp;lt;code&amp;gt;grep &amp;quot; 401 &amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
  * Everything auth related: &amp;lt;code&amp;gt;grep -E &amp;quot;(auth|password|mismatch|not found|remote_user)&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;&#039;&#039;Quick Copy-Paste Commands &#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
# Watch failed guesses live&lt;br /&gt;
docker compose logs -f nginx | grep -E &amp;quot;mismatch|not found|401&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Check successful logins&lt;br /&gt;
docker compose logs nginx | grep &amp;quot;remote_user&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Combined quick check (recommended)&lt;br /&gt;
docker compose logs -f --tail=100 nginx | grep -E &amp;quot;(mismatch|not found|remote_user|401)&amp;quot;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
[[Category:Docker]]&lt;br /&gt;
[[Category:Ubuntu]]&lt;br /&gt;
[[Category:Hive]]&lt;br /&gt;
[[Category:v4call]]&lt;br /&gt;
[[Category:WebRTC]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Main_Page&amp;diff=739</id>
		<title>Main Page</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Main_Page&amp;diff=739"/>
		<updated>2026-04-19T14:16:23Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Licenses */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;br /&gt;
=In Concept development Mode - a wiki you can download=&lt;br /&gt;
= CompleteNoobs: A Downloadable Wiki for Reproducible Computer Tutorials =&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;CompleteNoobs&#039;&#039;&#039; is a open-source wiki offering free, reproducible computer science tutorials. &lt;br /&gt;
&lt;br /&gt;
* [[Ubuntu2404_Install_Docker_and_Docker_Compose| Download the wiki as a &#039;&#039;&#039;Docker image&#039;&#039;&#039; to run locally on your computer or fork it to contribute and share knowledge.]]&lt;br /&gt;
&lt;br /&gt;
== About CompleteNoobs ==&lt;br /&gt;
&lt;br /&gt;
Our mission is to provide accessible, libre-licensed resources for hobbyists, sysadmins, students, teachers, and computer science enthusiasts. All content is available under a &#039;&#039;&#039;Creative Commons BY-NC-SA&#039;&#039;&#039; license, ensuring the freedoms to:&lt;br /&gt;
* Read&lt;br /&gt;
* Edit/Modify&lt;br /&gt;
* Copy&lt;br /&gt;
* Share freely&lt;br /&gt;
&lt;br /&gt;
Download the wiki as an XML file at [https://xml.completenoobs.com xml.completenoobs.com] or access data-heavy content (images, videos, audio) via &#039;&#039;&#039;IPFS&#039;&#039;&#039; hashes.&lt;br /&gt;
&lt;br /&gt;
== Get Started ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Download the Wiki&#039;&#039;&#039;: Run CompleteNoobs locally using our [[Local_CompleteNoobs_Wiki|Manually install guides or Docker image]].&lt;br /&gt;
* &#039;&#039;&#039;Host Your Own&#039;&#039;&#039;: Set up your own MediaWiki instance with our guide: [[Host_Your_Own_Mediawiki_Online|Host Your Own MediaWiki Online]].&lt;br /&gt;
&lt;br /&gt;
== CompleteNoobs Blockchain Project ==&lt;br /&gt;
&lt;br /&gt;
As Noobs we want to learn more about bitcoin, without losing any real bitcoin&lt;br /&gt;
&lt;br /&gt;
First we are learning how to fork bitcoin version 0.14.3, best way to learn is by tinkering with it.&lt;br /&gt;
* [[N33Bcoin| n33b.com]]&lt;br /&gt;
&lt;br /&gt;
== Essential Links ==&lt;br /&gt;
&lt;br /&gt;
* [[Main_Index|Main Index Page]]&lt;br /&gt;
* [[Special:AllPages|All Pages]]&lt;br /&gt;
* [[Wiki_Basic_Syntax|Wiki Basic Syntax]]&lt;br /&gt;
* [[COMPLETENOOBS_FUNDING|Support Us]]&lt;br /&gt;
&lt;br /&gt;
== Licenses ==&lt;br /&gt;
[[LICENCE_HEADERS]]&lt;br /&gt;
All content is libre-licensed for free copying, modification, and distribution. Add a license header to your contributions using:&lt;br /&gt;
&amp;lt;pre&amp;gt;{{:LICENCE_HEADER_CC0}}&amp;lt;/pre&amp;gt;&lt;br /&gt;
{{:LICENCE_HEADER_CC0}}&lt;br /&gt;
&lt;br /&gt;
== Disclaimer ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
&#039;&#039;&#039;&#039;&#039;DISCLAIMER:&#039;&#039;&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
Content on CompleteNoobs is for informational and educational purposes only. We make no warranties about accuracy or reliability. Use at your own risk. Links to external sites are not endorsements, and we are not responsible for their content or availability. The site may be temporarily unavailable due to technical issues beyond our control.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Special:ContributionScores/10/5}}&lt;br /&gt;
{{Special:PopularPages/10}}&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_-_How_to_change_Active_and_Posting_keys_on_Hive&amp;diff=738</id>
		<title>Hive Blockchain - How to change Active and Posting keys on Hive</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_-_How_to_change_Active_and_Posting_keys_on_Hive&amp;diff=738"/>
		<updated>2026-04-19T13:57:14Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;&amp;#039;&amp;#039;&amp;#039;How to Change Your Active and Posting Keys on Hive (2026)&amp;#039;&amp;#039;&amp;#039;   To update your Active, Posting (or any) keys on Hive, you must perform a full **password reset**. This generates a new &amp;#039;&amp;#039;&amp;#039;Master Password&amp;#039;&amp;#039;&amp;#039; that automatically creates a fresh set of all keys (Owner, Active, Posting, Memo).  ==&amp;#039;&amp;#039;&amp;#039;Critical Warnings&amp;#039;&amp;#039;&amp;#039;== * If you lose the new Master Password or Owner Key, &amp;#039;&amp;#039;&amp;#039;no one&amp;#039;&amp;#039;&amp;#039; (not even Hive support) can recover your account. * &amp;#039;&amp;#039;&amp;#039;Backup everything&amp;#039;&amp;#039;&amp;#039; (especially the...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;&#039;How to Change Your Active and Posting Keys on Hive (2026)&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
To update your Active, Posting (or any) keys on Hive, you must perform a full **password reset**. This generates a new &#039;&#039;&#039;Master Password&#039;&#039;&#039; that automatically creates a fresh set of all keys (Owner, Active, Posting, Memo).&lt;br /&gt;
&lt;br /&gt;
==&#039;&#039;&#039;Critical Warnings&#039;&#039;&#039;==&lt;br /&gt;
* If you lose the new Master Password or Owner Key, &#039;&#039;&#039;no one&#039;&#039;&#039; (not even Hive support) can recover your account.&lt;br /&gt;
* &#039;&#039;&#039;Backup everything&#039;&#039;&#039; (especially the new Master Password and all private keys) before clicking Update.&lt;br /&gt;
* You will need to manually add the new keys to Hive Keychain and other apps afterward.&lt;br /&gt;
&lt;br /&gt;
==&#039;&#039;&#039;Method 1: Official Hive Wallet (wallet.hive.blog)&#039;&#039;&#039;==&lt;br /&gt;
# Go to [https://wallet.hive.blog wallet.hive.blog] (or directly to &amp;lt;code&amp;gt;https://wallet.hive.blog/@yourusername/permissions&amp;lt;/code&amp;gt;).&lt;br /&gt;
# Log in using your current &#039;&#039;&#039;Owner Key&#039;&#039;&#039; or &#039;&#039;&#039;Master Password&#039;&#039;&#039;.&lt;br /&gt;
# Navigate to the &#039;&#039;&#039;Change Password&#039;&#039;&#039; section.&lt;br /&gt;
# Click &#039;&#039;&#039;&amp;quot;Click to Generate Password&amp;quot;&#039;&#039;&#039;.&lt;br /&gt;
# &#039;&#039;&#039;Save everything&#039;&#039;&#039;: Copy the new Master Password and all displayed private/public keys. Store them securely offline.&lt;br /&gt;
# Re-enter the new Master Password in the confirmation field.&lt;br /&gt;
# Check all confirmation boxes.&lt;br /&gt;
# Click &#039;&#039;&#039;Update Password&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==&#039;&#039;&#039;Method 2: Using PeakD (often easier)&#039;&#039;&#039;==&lt;br /&gt;
# Log in to [https://peakd.com peakd.com] (usually via Hive Keychain).&lt;br /&gt;
# Go to your profile → &#039;&#039;&#039;Wallet&#039;&#039;&#039; → &#039;&#039;&#039;Keys &amp;amp; Permissions&#039;&#039;&#039;.&lt;br /&gt;
# Click the &#039;&#039;&#039;Change Password&#039;&#039;&#039; button.&lt;br /&gt;
# Authorize with your current &#039;&#039;&#039;Owner Key&#039;&#039;&#039;.&lt;br /&gt;
# Generate the new password, download/save all keys, then confirm the update.&lt;br /&gt;
&lt;br /&gt;
==&#039;&#039;&#039;After Updating&#039;&#039;&#039;==&lt;br /&gt;
* Update Hive Keychain (remove old keys and import the new ones).&lt;br /&gt;
* Update any mobile apps (Ecency, Hive Wallet, etc.).&lt;br /&gt;
* Test logging in with the new keys before deleting old backups.&lt;br /&gt;
&lt;br /&gt;
*&#039;&#039;&#039;Tip&#039;&#039;&#039;: Always keep your Owner Key extremely safe — it is the only key that can reset everything.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
==Expanding info box==&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
&#039;&#039;&#039;How to Change Your Active and Posting Keys on Hive (2026)&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To update your Active, Posting (or any) keys on Hive, you must perform a full **password reset**. This generates a new &#039;&#039;&#039;Master Password&#039;&#039;&#039; that automatically creates a fresh set of all keys (Owner, Active, Posting, Memo).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Critical Warnings&#039;&#039;&#039;&lt;br /&gt;
* If you lose the new Master Password or Owner Key, &#039;&#039;&#039;no one&#039;&#039;&#039; (not even Hive support) can recover your account.&lt;br /&gt;
* &#039;&#039;&#039;Backup everything&#039;&#039;&#039; (especially the new Master Password and all private keys) before clicking Update.&lt;br /&gt;
* You will need to manually add the new keys to Hive Keychain and other apps afterward.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Method 1: Official Hive Wallet (wallet.hive.blog)&#039;&#039;&#039;&lt;br /&gt;
# Go to [https://wallet.hive.blog wallet.hive.blog] (or directly to &amp;lt;code&amp;gt;https://wallet.hive.blog/@yourusername/permissions&amp;lt;/code&amp;gt;).&lt;br /&gt;
# Log in using your current &#039;&#039;&#039;Owner Key&#039;&#039;&#039; or &#039;&#039;&#039;Master Password&#039;&#039;&#039;.&lt;br /&gt;
# Navigate to the &#039;&#039;&#039;Change Password&#039;&#039;&#039; section.&lt;br /&gt;
# Click &#039;&#039;&#039;&amp;quot;Click to Generate Password&amp;quot;&#039;&#039;&#039;.&lt;br /&gt;
# &#039;&#039;&#039;Save everything&#039;&#039;&#039;: Copy the new Master Password and all displayed private/public keys. Store them securely offline.&lt;br /&gt;
# Re-enter the new Master Password in the confirmation field.&lt;br /&gt;
# Check all confirmation boxes.&lt;br /&gt;
# Click &#039;&#039;&#039;Update Password&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Method 2: Using PeakD (often easier)&#039;&#039;&#039;&lt;br /&gt;
# Log in to [https://peakd.com peakd.com] (usually via Hive Keychain).&lt;br /&gt;
# Go to your profile → &#039;&#039;&#039;Wallet&#039;&#039;&#039; → &#039;&#039;&#039;Keys &amp;amp; Permissions&#039;&#039;&#039;.&lt;br /&gt;
# Click the &#039;&#039;&#039;Change Password&#039;&#039;&#039; button.&lt;br /&gt;
# Authorize with your current &#039;&#039;&#039;Owner Key&#039;&#039;&#039;.&lt;br /&gt;
# Generate the new password, download/save all keys, then confirm the update.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;After Updating&#039;&#039;&#039;&lt;br /&gt;
* Update Hive Keychain (remove old keys and import the new ones).&lt;br /&gt;
* Update any mobile apps (Ecency, Hive Wallet, etc.).&lt;br /&gt;
* Test logging in with the new keys before deleting old backups.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Tip&#039;&#039;&#039;: Always keep your Owner Key extremely safe — it is the only key that can reset everything.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_-_buying/exchanging_Hive_Based_Dollars_$HBD&amp;diff=737</id>
		<title>Hive Blockchain - buying/exchanging Hive Based Dollars $HBD</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_-_buying/exchanging_Hive_Based_Dollars_$HBD&amp;diff=737"/>
		<updated>2026-04-19T12:48:22Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;= Hive Blockchain Buying HBD =  == Prerequisites == Before buying HBD, you must have a Hive account.  It is recommended to use Hive Keychain for easy and secure access to your account.  * Create a Hive account (see: &amp;#039;&amp;#039;Hive Blockchain Create Extra Accounts&amp;#039;&amp;#039;) * Install Hive Keychain (browser extension) * Log in to your account using your keys  == Buying HIVE from an Exchange == To obtain HBD, you will first need to buy HIVE from a cryptocurrency exchange.  Example (UK): *...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Hive Blockchain Buying HBD =&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
Before buying HBD, you must have a Hive account.&lt;br /&gt;
&lt;br /&gt;
It is recommended to use Hive Keychain for easy and secure access to your account.&lt;br /&gt;
&lt;br /&gt;
* Create a Hive account (see: &#039;&#039;Hive Blockchain Create Extra Accounts&#039;&#039;)&lt;br /&gt;
* Install Hive Keychain (browser extension)&lt;br /&gt;
* Log in to your account using your keys&lt;br /&gt;
&lt;br /&gt;
== Buying HIVE from an Exchange ==&lt;br /&gt;
To obtain HBD, you will first need to buy HIVE from a cryptocurrency exchange.&lt;br /&gt;
&lt;br /&gt;
Example (UK):&lt;br /&gt;
* https://www.mexc.com/en-GB/&lt;br /&gt;
&lt;br /&gt;
Steps:&lt;br /&gt;
* Create and verify an account on the exchange&lt;br /&gt;
* Deposit funds (GBP, EUR, etc.)&lt;br /&gt;
* Buy HIVE&lt;br /&gt;
&lt;br /&gt;
== Sending HIVE to Your Hive Account ==&lt;br /&gt;
Once you have purchased HIVE:&lt;br /&gt;
&lt;br /&gt;
* Go to &#039;&#039;&#039;Withdraw&#039;&#039;&#039; on the exchange&lt;br /&gt;
* Enter your Hive username as the destination&lt;br /&gt;
* Double-check the username (transactions cannot be reversed)&lt;br /&gt;
* Send the HIVE&lt;br /&gt;
&lt;br /&gt;
After a short time, the HIVE will appear in your Hive wallet.&lt;br /&gt;
&lt;br /&gt;
== Swapping HIVE to HBD ==&lt;br /&gt;
To convert HIVE into HBD, you can use a Hive-based exchange:&lt;br /&gt;
&lt;br /&gt;
* https://hivedex.io/&lt;br /&gt;
&lt;br /&gt;
Steps:&lt;br /&gt;
* Connect your Hive account (via Hive Keychain)&lt;br /&gt;
* Select the HIVE → HBD market&lt;br /&gt;
* Enter the amount of HIVE to swap&lt;br /&gt;
* Confirm the transaction in Hive Keychain&lt;br /&gt;
&lt;br /&gt;
Once completed, your HBD balance will be updated in your wallet.&lt;br /&gt;
&lt;br /&gt;
== Notes ==&lt;br /&gt;
* Always verify URLs before logging in or signing transactions&lt;br /&gt;
* Keep your keys secure and never share them&lt;br /&gt;
* Test with a small amount first if you are new&lt;br /&gt;
&lt;br /&gt;
And that&#039;s it — you now have HBD in your Hive account.&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Create_Extra_Accounts&amp;diff=736</id>
		<title>Hive Blockchain Create Create Extra Accounts</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Create_Extra_Accounts&amp;diff=736"/>
		<updated>2026-04-19T12:33:01Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;= Hive Blockchain Create Extra Accounts =  == Free accounts == This is the on-ramp for your first account.  https://signup.hive.io/  == Paid accounts ==  === Hive Keychain ===  * Download Hive Keychain (browser extension) * Log in with your &amp;#039;&amp;#039;&amp;#039;Active Key&amp;#039;&amp;#039;&amp;#039; (required to sign the ~3 HIVE account creation fee) * Click the ☰ (top left) → &amp;#039;&amp;#039;&amp;#039;Accounts&amp;#039;&amp;#039;&amp;#039; → &amp;#039;&amp;#039;&amp;#039;Create Account&amp;#039;&amp;#039;&amp;#039; * Enter a username  If the username is available, you will proceed to the next stage where you...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Hive Blockchain Create Extra Accounts =&lt;br /&gt;
&lt;br /&gt;
== Free accounts ==&lt;br /&gt;
This is the on-ramp for your first account.&lt;br /&gt;
&lt;br /&gt;
https://signup.hive.io/&lt;br /&gt;
&lt;br /&gt;
== Paid accounts ==&lt;br /&gt;
&lt;br /&gt;
=== Hive Keychain ===&lt;br /&gt;
&lt;br /&gt;
* Download Hive Keychain (browser extension)&lt;br /&gt;
* Log in with your &#039;&#039;&#039;Active Key&#039;&#039;&#039; (required to sign the ~3 HIVE account creation fee)&lt;br /&gt;
* Click the ☰ (top left) → &#039;&#039;&#039;Accounts&#039;&#039;&#039; → &#039;&#039;&#039;Create Account&#039;&#039;&#039;&lt;br /&gt;
* Enter a username&lt;br /&gt;
&lt;br /&gt;
If the username is available, you will proceed to the next stage where your key pairs are shown.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important:&#039;&#039;&#039;&lt;br /&gt;
When Hive Keychain is open on the key display screen, clicking outside the extension (e.g. to open a text editor) may close it. If this happens before saving, you will lose the generated keys and must restart the process.&lt;br /&gt;
&lt;br /&gt;
* As soon as the keys are shown:&lt;br /&gt;
** Click &#039;&#039;&#039;Copy&#039;&#039;&#039;&lt;br /&gt;
** Tick all confirmation boxes&lt;br /&gt;
** Click &#039;&#039;&#039;Copy&#039;&#039;&#039; again to be safe&lt;br /&gt;
* Click &#039;&#039;&#039;Create&#039;&#039;&#039; to finalize the account&lt;br /&gt;
* Immediately paste your keys into a text file and back them up securely&lt;br /&gt;
&lt;br /&gt;
And that&#039;s it — you have created a new Hive account.&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=735</id>
		<title>Hive Blockchain Create Custom Coins</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=735"/>
		<updated>2026-04-19T12:23:06Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{:LICENCE_HEADER_CC0}}&lt;br /&gt;
= v4call — How to Create Your Own Custom Token (e.g. CNOOBS) on Hive Engine =&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
F&lt;br /&gt;
This guide walks you through creating a custom Hive Engine token (example: &#039;&#039;&#039;CNOOBS&#039;&#039;&#039;) in under 10 minutes. No coding required.&lt;br /&gt;
&lt;br /&gt;
These tokens power the custom communication economy in v4call: you can set rates like &amp;quot;1 CNOOBS = 1 text message&amp;quot; or &amp;quot;10 CNOOBS = 1 hour video call&amp;quot;, gift them to friends/family, or let blocked users bypass your blocklist if they hold enough of your token.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why create your own token?&#039;&#039;&#039;&lt;br /&gt;
* Full control over supply and distribution.&lt;br /&gt;
* Real utility inside v4call (and any other Hive dApp that supports Hive Engine tokens).&lt;br /&gt;
* Scarcity you decide — perfect for personal or community communication economies.&lt;br /&gt;
* Transferable, tradable, and giftable via Hive Keychain or TribalDex.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Cost&#039;&#039;&#039;: Approximately 100 BEE (Hive Engine&#039;s utility token). BEE price fluctuates — check current value on TribalDex or PeakD. This is a one-time creation fee.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Source&#039;&#039;&#039;: Based on the official Hive Engine / TribalDex interface (as of 2026).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;End result&#039;&#039;&#039;: A live custom token (e.g. CNOOBS) that appears in users&#039; Hive Keychain wallets and can be used in your v4call rates post.&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
* [[#What_You_Need|1 What You Need]]&lt;br /&gt;
* [[#Step_1:_Get_Some_BEE_Tokens|2 Step 1: Get Some BEE Tokens]]&lt;br /&gt;
* [[#Step_2:_Log_In_to_TribalDex|3 Step 2: Log In to TribalDex]]&lt;br /&gt;
* [[#Step_3:_Create_Your_Token|4 Step 3: Create Your Token]]&lt;br /&gt;
* [[#Step_4:_Issue_Tokens_to_Yourself|5 Step 4: Issue Tokens to Yourself]]&lt;br /&gt;
* [[#Step_5:_Optional_-_Add_Metadata_(Logo_Description)|6 Step 5: Optional - Add Metadata (Logo &amp;amp; Description)]]&lt;br /&gt;
* [[#Step_6:_Distribute_Your_Tokens|7 Step 6: Distribute Your Tokens]]&lt;br /&gt;
* [[#Using_Your_Token_in_v4call|8 Using Your Token in v4call]]&lt;br /&gt;
* [[#Common_Problems_and_Fixes|9 Common Problems and Fixes]]&lt;br /&gt;
* [[#Quick_Reference|10 Quick Reference]]&lt;br /&gt;
&lt;br /&gt;
== What You Need ==&lt;br /&gt;
* A Hive account (e.g. @noblemage) with Hive Keychain installed and the &#039;&#039;&#039;active key&#039;&#039;&#039; available.&lt;br /&gt;
* Some BEE tokens (≈100 BEE for creation fee).&lt;br /&gt;
* A web browser.&lt;br /&gt;
&lt;br /&gt;
You do &#039;&#039;&#039;not&#039;&#039;&#039; need to run a node or write code.&lt;br /&gt;
&lt;br /&gt;
== Step 1: Get Some BEE Tokens ==&lt;br /&gt;
BEE is the &amp;quot;gas&amp;quot; token for Hive Engine actions.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com tribaldex.com] or any Hive Engine market (e.g. via PeakD wallet).&lt;br /&gt;
# Swap or buy BEE using HIVE or HBD (very easy with Keychain).&lt;br /&gt;
# Alternative: Many users get small amounts of BEE from community airdrops or by swapping on the built-in market.&lt;br /&gt;
&lt;br /&gt;
Make sure you have at least 110 BEE to cover the fee plus a small buffer.&lt;br /&gt;
&lt;br /&gt;
== Step 2: Log In to TribalDex ==&lt;br /&gt;
# Visit [https://tribaldex.com/tokens/create https://tribaldex.com/tokens/create]&lt;br /&gt;
# Click the login button at the top.&lt;br /&gt;
# Hive Keychain will pop up — approve the login with your &#039;&#039;&#039;active key&#039;&#039;&#039; (or posting key in some cases, but active is safest for token actions).&lt;br /&gt;
&lt;br /&gt;
You are now logged in as your Hive account.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Create Your Token ==&lt;br /&gt;
On the token creation form, fill in the following fields carefully. Most cannot be changed later.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Symbol&#039;&#039;&#039; — e.g. &#039;&#039;&#039;CNOOBS&#039;&#039;&#039; (uppercase, 1–10 characters, unique)&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; — e.g. &#039;&#039;&#039;Cnoobs Coins&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Max Supply&#039;&#039;&#039; — Choose a number (e.g. 1,000,000). This is the absolute maximum that can ever exist. You can issue less.&lt;br /&gt;
* &#039;&#039;&#039;Precision&#039;&#039;&#039; — Usually &#039;&#039;&#039;3&#039;&#039;&#039; (allows 0.001 precision). You can only increase this later, not decrease.&lt;br /&gt;
* &#039;&#039;&#039;Website&#039;&#039;&#039; (optional) — Link to your profile or v4call server (e.g. https://call.yourdomain.com)&lt;br /&gt;
* &#039;&#039;&#039;Description&#039;&#039;&#039; (optional) — Short text like &amp;quot;Personal communication token for v4call — used for messages and calls with @cnoobz&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Double-check everything — the symbol especially cannot be changed.&lt;br /&gt;
&lt;br /&gt;
Click &#039;&#039;&#039;Create Token&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Hive Keychain will ask you to approve a custom_json transaction. Confirm it.&lt;br /&gt;
&lt;br /&gt;
Wait 3–10 seconds for confirmation on the blockchain.&lt;br /&gt;
&lt;br /&gt;
Success message should appear: your token is now created!&lt;br /&gt;
&lt;br /&gt;
== Step 4: Issue Tokens to Yourself ==&lt;br /&gt;
After creation, you usually start with zero balance.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com/tokens/manage https://tribaldex.com/tokens/manage]&lt;br /&gt;
# Find your new token (CNOOBS) in the list.&lt;br /&gt;
# Click the &#039;&#039;&#039;Issue&#039;&#039;&#039; button.&lt;br /&gt;
# Enter the amount you want to issue to yourself (e.g. 10000).&lt;br /&gt;
# Confirm with Keychain.&lt;br /&gt;
&lt;br /&gt;
You now hold the full issued supply.&lt;br /&gt;
&lt;br /&gt;
== Step 5: Optional — Add Metadata (Logo &amp;amp; Description) ==&lt;br /&gt;
# Still on the manage page, click the edit icon for your token.&lt;br /&gt;
# Upload a square logo image (recommended 200x200 px or larger).&lt;br /&gt;
# Improve the description and website link.&lt;br /&gt;
# Save changes (another small Keychain transaction).&lt;br /&gt;
&lt;br /&gt;
This makes your token look professional when users view it in wallets or markets.&lt;br /&gt;
&lt;br /&gt;
== Step 6: Distribute Your Tokens ==&lt;br /&gt;
* Send to friends/family via Keychain → &amp;quot;Tokens&amp;quot; tab → Transfer.&lt;br /&gt;
* Airdrop to your community.&lt;br /&gt;
* Use in v4call rates (see below).&lt;br /&gt;
* List on TribalDex market if you want people to buy/sell them.&lt;br /&gt;
&lt;br /&gt;
Tokens are fully transferable and appear instantly in recipients&#039; Hive Keychain wallets.&lt;br /&gt;
&lt;br /&gt;
== Using Your Token in v4call ==&lt;br /&gt;
Once you have your token:&lt;br /&gt;
&lt;br /&gt;
# Go to your v4call server → /rate-editor.html&lt;br /&gt;
# In the rate editor (V2 format), create a new list like [LIST:token-holders]&lt;br /&gt;
# Set TOKEN:CNOOBS&lt;br /&gt;
# Define rates, e.g.:&lt;br /&gt;
** TEXT:1 CNOOBS&lt;br /&gt;
** VOICE:RING:5 CNOOBS;CONNECT:10 CNOOBS;RATE:20 CNOOBS/hr&lt;br /&gt;
# Your server will automatically verify balances using public Hive Engine RPC when someone tries to call or message you.&lt;br /&gt;
&lt;br /&gt;
Blocked users can bypass your blocklist if you set ALLOW-IF-TOKEN:CNOOBS and they hold enough.&lt;br /&gt;
&lt;br /&gt;
== Common Problems and Fixes ==&lt;br /&gt;
=== &amp;quot;Not enough BEE&amp;quot; error ===&lt;br /&gt;
Buy or swap more BEE on TribalDex.&lt;br /&gt;
&lt;br /&gt;
=== Token symbol already taken ===&lt;br /&gt;
Choose a different symbol (add numbers or make it longer, e.g. CNOOBS2).&lt;br /&gt;
&lt;br /&gt;
=== Transaction fails ===&lt;br /&gt;
Make sure you are using the &#039;&#039;&#039;active key&#039;&#039;&#039; in Keychain for token creation/issuing. Restart Keychain if needed.&lt;br /&gt;
&lt;br /&gt;
=== Can&#039;t find my token after creation ===&lt;br /&gt;
Refresh the page or check https://hive-engine.com/tokens — it may take a few seconds to appear.&lt;br /&gt;
&lt;br /&gt;
=== Want to issue more later? ===&lt;br /&gt;
You can issue up to your max supply at any time from the manage page.&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Action !! Where to Do It&lt;br /&gt;
|-&lt;br /&gt;
| Create token || https://tribaldex.com/tokens/create&lt;br /&gt;
|-&lt;br /&gt;
| Manage / Issue tokens || https://tribaldex.com/tokens/manage&lt;br /&gt;
|-&lt;br /&gt;
| View all tokens || https://hive-engine.com/tokens&lt;br /&gt;
|-&lt;br /&gt;
| Swap BEE || TribalDex market&lt;br /&gt;
|-&lt;br /&gt;
| Check balance in Keychain || Hive Keychain extension → Tokens tab&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Next step for v4call users&#039;&#039;&#039;: After creating your token, update your v4call-rates post using the improved rate-editor.html to include your custom token rates and blocklist bypass.&lt;br /&gt;
&lt;br /&gt;
You now have your own personal communication currency on Hive!&lt;br /&gt;
&lt;br /&gt;
[[Category:Hive]]&lt;br /&gt;
[[Category:Hive Engine]]&lt;br /&gt;
[[Category:v4call]]&lt;br /&gt;
[[Category:Token Creation]]&lt;br /&gt;
[[Category:Web3]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=734</id>
		<title>Hive Blockchain Create Custom Coins</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Hive_Blockchain_Create_Custom_Coins&amp;diff=734"/>
		<updated>2026-04-19T12:22:14Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;{{LICENCE_CC0}} = v4call — How to Create Your Own Custom Token (e.g. CNOOBS) on Hive Engine = From CompleteNoobs F This guide walks you through creating a custom Hive Engine token (example: &amp;#039;&amp;#039;&amp;#039;CNOOBS&amp;#039;&amp;#039;&amp;#039;) in under 10 minutes. No coding required.  These tokens power the custom communication economy in v4call: you can set rates like &amp;quot;1 CNOOBS = 1 text message&amp;quot; or &amp;quot;10 CNOOBS = 1 hour video call&amp;quot;, gift them to friends/family, or let blocked users bypass your blocklist if th...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{LICENCE_CC0}}&lt;br /&gt;
= v4call — How to Create Your Own Custom Token (e.g. CNOOBS) on Hive Engine =&lt;br /&gt;
From CompleteNoobs&lt;br /&gt;
F&lt;br /&gt;
This guide walks you through creating a custom Hive Engine token (example: &#039;&#039;&#039;CNOOBS&#039;&#039;&#039;) in under 10 minutes. No coding required.&lt;br /&gt;
&lt;br /&gt;
These tokens power the custom communication economy in v4call: you can set rates like &amp;quot;1 CNOOBS = 1 text message&amp;quot; or &amp;quot;10 CNOOBS = 1 hour video call&amp;quot;, gift them to friends/family, or let blocked users bypass your blocklist if they hold enough of your token.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why create your own token?&#039;&#039;&#039;&lt;br /&gt;
* Full control over supply and distribution.&lt;br /&gt;
* Real utility inside v4call (and any other Hive dApp that supports Hive Engine tokens).&lt;br /&gt;
* Scarcity you decide — perfect for personal or community communication economies.&lt;br /&gt;
* Transferable, tradable, and giftable via Hive Keychain or TribalDex.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Cost&#039;&#039;&#039;: Approximately 100 BEE (Hive Engine&#039;s utility token). BEE price fluctuates — check current value on TribalDex or PeakD. This is a one-time creation fee.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Source&#039;&#039;&#039;: Based on the official Hive Engine / TribalDex interface (as of 2026).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;End result&#039;&#039;&#039;: A live custom token (e.g. CNOOBS) that appears in users&#039; Hive Keychain wallets and can be used in your v4call rates post.&lt;br /&gt;
&lt;br /&gt;
== Contents ==&lt;br /&gt;
* [[#What_You_Need|1 What You Need]]&lt;br /&gt;
* [[#Step_1:_Get_Some_BEE_Tokens|2 Step 1: Get Some BEE Tokens]]&lt;br /&gt;
* [[#Step_2:_Log_In_to_TribalDex|3 Step 2: Log In to TribalDex]]&lt;br /&gt;
* [[#Step_3:_Create_Your_Token|4 Step 3: Create Your Token]]&lt;br /&gt;
* [[#Step_4:_Issue_Tokens_to_Yourself|5 Step 4: Issue Tokens to Yourself]]&lt;br /&gt;
* [[#Step_5:_Optional_-_Add_Metadata_(Logo_Description)|6 Step 5: Optional - Add Metadata (Logo &amp;amp; Description)]]&lt;br /&gt;
* [[#Step_6:_Distribute_Your_Tokens|7 Step 6: Distribute Your Tokens]]&lt;br /&gt;
* [[#Using_Your_Token_in_v4call|8 Using Your Token in v4call]]&lt;br /&gt;
* [[#Common_Problems_and_Fixes|9 Common Problems and Fixes]]&lt;br /&gt;
* [[#Quick_Reference|10 Quick Reference]]&lt;br /&gt;
&lt;br /&gt;
== What You Need ==&lt;br /&gt;
* A Hive account (e.g. @noblemage) with Hive Keychain installed and the &#039;&#039;&#039;active key&#039;&#039;&#039; available.&lt;br /&gt;
* Some BEE tokens (≈100 BEE for creation fee).&lt;br /&gt;
* A web browser.&lt;br /&gt;
&lt;br /&gt;
You do &#039;&#039;&#039;not&#039;&#039;&#039; need to run a node or write code.&lt;br /&gt;
&lt;br /&gt;
== Step 1: Get Some BEE Tokens ==&lt;br /&gt;
BEE is the &amp;quot;gas&amp;quot; token for Hive Engine actions.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com tribaldex.com] or any Hive Engine market (e.g. via PeakD wallet).&lt;br /&gt;
# Swap or buy BEE using HIVE or HBD (very easy with Keychain).&lt;br /&gt;
# Alternative: Many users get small amounts of BEE from community airdrops or by swapping on the built-in market.&lt;br /&gt;
&lt;br /&gt;
Make sure you have at least 110 BEE to cover the fee plus a small buffer.&lt;br /&gt;
&lt;br /&gt;
== Step 2: Log In to TribalDex ==&lt;br /&gt;
# Visit [https://tribaldex.com/tokens/create https://tribaldex.com/tokens/create]&lt;br /&gt;
# Click the login button at the top.&lt;br /&gt;
# Hive Keychain will pop up — approve the login with your &#039;&#039;&#039;active key&#039;&#039;&#039; (or posting key in some cases, but active is safest for token actions).&lt;br /&gt;
&lt;br /&gt;
You are now logged in as your Hive account.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Create Your Token ==&lt;br /&gt;
On the token creation form, fill in the following fields carefully. Most cannot be changed later.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Symbol&#039;&#039;&#039; — e.g. &#039;&#039;&#039;CNOOBS&#039;&#039;&#039; (uppercase, 1–10 characters, unique)&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; — e.g. &#039;&#039;&#039;Cnoobs Coins&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Max Supply&#039;&#039;&#039; — Choose a number (e.g. 1,000,000). This is the absolute maximum that can ever exist. You can issue less.&lt;br /&gt;
* &#039;&#039;&#039;Precision&#039;&#039;&#039; — Usually &#039;&#039;&#039;3&#039;&#039;&#039; (allows 0.001 precision). You can only increase this later, not decrease.&lt;br /&gt;
* &#039;&#039;&#039;Website&#039;&#039;&#039; (optional) — Link to your profile or v4call server (e.g. https://call.yourdomain.com)&lt;br /&gt;
* &#039;&#039;&#039;Description&#039;&#039;&#039; (optional) — Short text like &amp;quot;Personal communication token for v4call — used for messages and calls with @cnoobz&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Double-check everything — the symbol especially cannot be changed.&lt;br /&gt;
&lt;br /&gt;
Click &#039;&#039;&#039;Create Token&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Hive Keychain will ask you to approve a custom_json transaction. Confirm it.&lt;br /&gt;
&lt;br /&gt;
Wait 3–10 seconds for confirmation on the blockchain.&lt;br /&gt;
&lt;br /&gt;
Success message should appear: your token is now created!&lt;br /&gt;
&lt;br /&gt;
== Step 4: Issue Tokens to Yourself ==&lt;br /&gt;
After creation, you usually start with zero balance.&lt;br /&gt;
&lt;br /&gt;
# Go to [https://tribaldex.com/tokens/manage https://tribaldex.com/tokens/manage]&lt;br /&gt;
# Find your new token (CNOOBS) in the list.&lt;br /&gt;
# Click the &#039;&#039;&#039;Issue&#039;&#039;&#039; button.&lt;br /&gt;
# Enter the amount you want to issue to yourself (e.g. 10000).&lt;br /&gt;
# Confirm with Keychain.&lt;br /&gt;
&lt;br /&gt;
You now hold the full issued supply.&lt;br /&gt;
&lt;br /&gt;
== Step 5: Optional — Add Metadata (Logo &amp;amp; Description) ==&lt;br /&gt;
# Still on the manage page, click the edit icon for your token.&lt;br /&gt;
# Upload a square logo image (recommended 200x200 px or larger).&lt;br /&gt;
# Improve the description and website link.&lt;br /&gt;
# Save changes (another small Keychain transaction).&lt;br /&gt;
&lt;br /&gt;
This makes your token look professional when users view it in wallets or markets.&lt;br /&gt;
&lt;br /&gt;
== Step 6: Distribute Your Tokens ==&lt;br /&gt;
* Send to friends/family via Keychain → &amp;quot;Tokens&amp;quot; tab → Transfer.&lt;br /&gt;
* Airdrop to your community.&lt;br /&gt;
* Use in v4call rates (see below).&lt;br /&gt;
* List on TribalDex market if you want people to buy/sell them.&lt;br /&gt;
&lt;br /&gt;
Tokens are fully transferable and appear instantly in recipients&#039; Hive Keychain wallets.&lt;br /&gt;
&lt;br /&gt;
== Using Your Token in v4call ==&lt;br /&gt;
Once you have your token:&lt;br /&gt;
&lt;br /&gt;
# Go to your v4call server → /rate-editor.html&lt;br /&gt;
# In the rate editor (V2 format), create a new list like [LIST:token-holders]&lt;br /&gt;
# Set TOKEN:CNOOBS&lt;br /&gt;
# Define rates, e.g.:&lt;br /&gt;
** TEXT:1 CNOOBS&lt;br /&gt;
** VOICE:RING:5 CNOOBS;CONNECT:10 CNOOBS;RATE:20 CNOOBS/hr&lt;br /&gt;
# Your server will automatically verify balances using public Hive Engine RPC when someone tries to call or message you.&lt;br /&gt;
&lt;br /&gt;
Blocked users can bypass your blocklist if you set ALLOW-IF-TOKEN:CNOOBS and they hold enough.&lt;br /&gt;
&lt;br /&gt;
== Common Problems and Fixes ==&lt;br /&gt;
=== &amp;quot;Not enough BEE&amp;quot; error ===&lt;br /&gt;
Buy or swap more BEE on TribalDex.&lt;br /&gt;
&lt;br /&gt;
=== Token symbol already taken ===&lt;br /&gt;
Choose a different symbol (add numbers or make it longer, e.g. CNOOBS2).&lt;br /&gt;
&lt;br /&gt;
=== Transaction fails ===&lt;br /&gt;
Make sure you are using the &#039;&#039;&#039;active key&#039;&#039;&#039; in Keychain for token creation/issuing. Restart Keychain if needed.&lt;br /&gt;
&lt;br /&gt;
=== Can&#039;t find my token after creation ===&lt;br /&gt;
Refresh the page or check https://hive-engine.com/tokens — it may take a few seconds to appear.&lt;br /&gt;
&lt;br /&gt;
=== Want to issue more later? ===&lt;br /&gt;
You can issue up to your max supply at any time from the manage page.&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Action !! Where to Do It&lt;br /&gt;
|-&lt;br /&gt;
| Create token || https://tribaldex.com/tokens/create&lt;br /&gt;
|-&lt;br /&gt;
| Manage / Issue tokens || https://tribaldex.com/tokens/manage&lt;br /&gt;
|-&lt;br /&gt;
| View all tokens || https://hive-engine.com/tokens&lt;br /&gt;
|-&lt;br /&gt;
| Swap BEE || TribalDex market&lt;br /&gt;
|-&lt;br /&gt;
| Check balance in Keychain || Hive Keychain extension → Tokens tab&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Next step for v4call users&#039;&#039;&#039;: After creating your token, update your v4call-rates post using the improved rate-editor.html to include your custom token rates and blocklist bypass.&lt;br /&gt;
&lt;br /&gt;
You now have your own personal communication currency on Hive!&lt;br /&gt;
&lt;br /&gt;
[[Category:Hive]]&lt;br /&gt;
[[Category:Hive Engine]]&lt;br /&gt;
[[Category:v4call]]&lt;br /&gt;
[[Category:Token Creation]]&lt;br /&gt;
[[Category:Web3]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Html_JavaScript_Cookies_PopUP_Accept_Basic&amp;diff=731</id>
		<title>Html JavaScript Cookies PopUP Accept Basic</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Html_JavaScript_Cookies_PopUP_Accept_Basic&amp;diff=731"/>
		<updated>2026-04-16T15:14:19Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;3 parts  ==Part one== * This is placed in the &amp;lt;head&amp;gt; &amp;lt;/head&amp;gt; at the top of index.html contains css &amp;lt;pre&amp;gt; &amp;lt;style&amp;gt;   #overlay {     position: fixed; top: 0; left: 0; width: 100%; height: 100%;     background: rgba(0,0,0,0.85); color: white; z-index: 10000;     display: flex; align-items: center; justify-content: center; text-align: center;   }   .popup-box { background: #222; padding: 30px; border-radius: 10px; border: 1px solid #444; }   button { padding: 10px 20px; curso...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;3 parts&lt;br /&gt;
&lt;br /&gt;
==Part one==&lt;br /&gt;
* This is placed in the &amp;lt;head&amp;gt; &amp;lt;/head&amp;gt; at the top of index.html contains css&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;style&amp;gt;&lt;br /&gt;
  #overlay {&lt;br /&gt;
    position: fixed; top: 0; left: 0; width: 100%; height: 100%;&lt;br /&gt;
    background: rgba(0,0,0,0.85); color: white; z-index: 10000;&lt;br /&gt;
    display: flex; align-items: center; justify-content: center; text-align: center;&lt;br /&gt;
  }&lt;br /&gt;
  .popup-box { background: #222; padding: 30px; border-radius: 10px; border: 1px solid #444; }&lt;br /&gt;
  button { padding: 10px 20px; cursor: pointer; background: #007bff; color: white; border: none; border-radius: 5px; }&lt;br /&gt;
  .hidden { display: none !important; }&lt;br /&gt;
&amp;lt;/style&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
==part two==&lt;br /&gt;
* This is placed in/at the top of the &amp;lt;body&amp;gt; &amp;lt;/body&amp;gt; section, contains message.&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!-- pop up cookies and other 2/3--&amp;gt;&lt;br /&gt;
&amp;lt;div id=&amp;quot;overlay&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;popup-box&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;h2&amp;gt;Welcome to Complete Noobs!&amp;lt;/h2&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;We use cookies to track traffic. Are you 21 or older?&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;By accepting you are entering at your own risk&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;button onclick=&amp;quot;acceptAndHide()&amp;quot;&amp;gt;Yes, I Accept &amp;amp; I&#039;m Over 21 &amp;amp; am aware you are noobs and are still learning i enter at my own risk&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;→ &amp;lt;a href=&amp;quot;https://www.google.com&amp;quot; &amp;gt;No Thank You - take me to Google&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==part 3==&lt;br /&gt;
* This is placed in/at the bottom of the &amp;lt;body&amp;gt; &amp;lt;/body&amp;gt; section, contains js script.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!-- pop up cookies and other  3/3--&amp;gt;&lt;br /&gt;
&amp;lt;script&amp;gt;&lt;br /&gt;
  function acceptAndHide() {&lt;br /&gt;
    // Save the choice in the browser&#039;s &amp;quot;localStorage&amp;quot; so it doesn&#039;t pop up again&lt;br /&gt;
    localStorage.setItem(&#039;gate_passed&#039;, &#039;true&#039;);&lt;br /&gt;
    document.getElementById(&#039;overlay&#039;).classList.add(&#039;hidden&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  // On page load, check if they already clicked &amp;quot;Yes&amp;quot;&lt;br /&gt;
  window.onload = function() {&lt;br /&gt;
    if (localStorage.getItem(&#039;gate_passed&#039;) === &#039;true&#039;) {&lt;br /&gt;
      document.getElementById(&#039;overlay&#039;).classList.add(&#039;hidden&#039;);&lt;br /&gt;
    }&lt;br /&gt;
  };&lt;br /&gt;
&amp;lt;/script&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
==use case==&lt;br /&gt;
* Sample use case&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br /&gt;
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;head&amp;gt;&lt;br /&gt;
    &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1.0&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;title&amp;gt;My Projects - Status Update&amp;lt;/title&amp;gt;&lt;br /&gt;
    &amp;lt;style&amp;gt;&lt;br /&gt;
        body {&lt;br /&gt;
            font-family: Arial, Helvetica, sans-serif;&lt;br /&gt;
            max-width: 800px;&lt;br /&gt;
            margin: 40px auto;&lt;br /&gt;
            padding: 20px;&lt;br /&gt;
            line-height: 1.6;&lt;br /&gt;
            color: #333;&lt;br /&gt;
        }&lt;br /&gt;
        h1 {&lt;br /&gt;
            text-align: center;&lt;br /&gt;
            color: #222;&lt;br /&gt;
        }&lt;br /&gt;
        h2 {&lt;br /&gt;
            color: #444;&lt;br /&gt;
            border-bottom: 2px solid #eee;&lt;br /&gt;
            padding-bottom: 8px;&lt;br /&gt;
        }&lt;br /&gt;
        a {&lt;br /&gt;
            color: #0066cc;&lt;br /&gt;
            text-decoration: none;&lt;br /&gt;
        }&lt;br /&gt;
        a:hover {&lt;br /&gt;
            text-decoration: underline;&lt;br /&gt;
        }&lt;br /&gt;
        .status {&lt;br /&gt;
            font-style: italic;&lt;br /&gt;
            color: #555;&lt;br /&gt;
        }&lt;br /&gt;
    &amp;lt;/style&amp;gt;&lt;br /&gt;
&amp;lt;!-- pop up cookies and other 1/3--&amp;gt;&lt;br /&gt;
&amp;lt;style&amp;gt;&lt;br /&gt;
  #overlay {&lt;br /&gt;
    position: fixed; top: 0; left: 0; width: 100%; height: 100%;&lt;br /&gt;
    background: rgba(0,0,0,0.85); color: white; z-index: 10000;&lt;br /&gt;
    display: flex; align-items: center; justify-content: center; text-align: center;&lt;br /&gt;
  }&lt;br /&gt;
  .popup-box { background: #222; padding: 30px; border-radius: 10px; border: 1px solid #444; }&lt;br /&gt;
  button { padding: 10px 20px; cursor: pointer; background: #007bff; color: white; border: none; border-radius: 5px; }&lt;br /&gt;
  .hidden { display: none !important; }&lt;br /&gt;
&amp;lt;/style&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/head&amp;gt;&lt;br /&gt;
&amp;lt;!--24-06-25 adding google analytic  --&amp;gt;&lt;br /&gt;
&amp;lt;!-- Google tag (gtag.js) --&amp;gt;&lt;br /&gt;
&amp;lt;script async src=&amp;quot;https://www.googletagmanager.com/gtag/js?id=G-YXYQE65XY1&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;&lt;br /&gt;
&amp;lt;script&amp;gt;&lt;br /&gt;
  window.dataLayer = window.dataLayer || [];&lt;br /&gt;
  function gtag(){dataLayer.push(arguments);}&lt;br /&gt;
  gtag(&#039;js&#039;, new Date());&lt;br /&gt;
&lt;br /&gt;
  gtag(&#039;config&#039;, &#039;G-YXYQE65XY1&#039;);&lt;br /&gt;
&amp;lt;/script&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;body&amp;gt;&lt;br /&gt;
&amp;lt;!-- pop up cookies and other 2/3--&amp;gt;&lt;br /&gt;
&amp;lt;div id=&amp;quot;overlay&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;popup-box&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;h2&amp;gt;Welcome to Complete Noobs!&amp;lt;/h2&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;We use cookies to track traffic. Are you 21 or older?&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;By accepting you are entering at your own risk&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;button onclick=&amp;quot;acceptAndHide()&amp;quot;&amp;gt;Yes, I Accept &amp;amp; I&#039;m Over 21 &amp;amp; am aware you are noobs and are still learning i enter at my own risk&amp;lt;/button&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;→ &amp;lt;a href=&amp;quot;https://www.google.com&amp;quot; &amp;gt;No Thank You - take me to Google&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;h1&amp;gt;Project Status&amp;lt;/h1&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;h2&amp;gt;CompleteNoobs&amp;lt;/h2&amp;gt;&lt;br /&gt;
    &amp;lt;p class=&amp;quot;status&amp;quot;&amp;gt;Currently on hold due to lack of free time.&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;It will be relaunched as &amp;lt;strong&amp;gt;cnoobs.com&amp;lt;/strong&amp;gt; running inside a Docker container.&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;Future plans include a possible soft-fork of MediaWiki that allows Hive accounts holding a certain amount of CNOOBS coins to post and edit pages. &lt;br /&gt;
       I&#039;m also interested in integrating the Hive rewards system for content creators and exploring reward splits between users.&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;Additional custom features being considered:&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;ul&amp;gt;&lt;br /&gt;
        &amp;lt;li&amp;gt;Custom tags for embedding IPFS content to keep the wiki database smaller and more portable:&amp;lt;/li&amp;gt;&lt;br /&gt;
        &amp;lt;ul&amp;gt;&lt;br /&gt;
            &amp;lt;li&amp;gt;&amp;lt;code&amp;gt;&amp;amp;lt;ipfs_video&amp;amp;gt;ipfs_addr&amp;amp;lt;/ipfs_video&amp;amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
            &amp;lt;li&amp;gt;&amp;lt;code&amp;gt;&amp;amp;lt;ipfs_pic&amp;amp;gt;ipfs_addr&amp;amp;lt;/ipfs_pic&amp;amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
            &amp;lt;li&amp;gt;&amp;lt;code&amp;gt;&amp;amp;lt;ipfs_audio&amp;amp;gt;ipfs_addr&amp;amp;lt;/ipfs_audio&amp;amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
            &amp;lt;li&amp;gt;&amp;lt;code&amp;gt;&amp;amp;lt;ipfs_file&amp;amp;gt;ipfs_addr&amp;amp;lt;/ipfs_file&amp;amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
        &amp;lt;/ul&amp;gt;&lt;br /&gt;
    &amp;lt;/ul&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;The old draft remains available but is unmaintained:&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;→ &amp;lt;a href=&amp;quot;https://www.completenoobs.com/noobs/Main_Page&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;CompleteNoobs.com (old wiki)&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;→ &amp;lt;a href=&amp;quot;https://xml.completenoobs.com&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;xml.CompleteNoobs.com (old xml downloads)&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;h2&amp;gt;n33b.com&amp;lt;/h2&amp;gt;&lt;br /&gt;
    &amp;lt;p class=&amp;quot;status&amp;quot;&amp;gt;Currently on hold.&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;It will &amp;lt;strong&amp;gt;not&amp;lt;/strong&amp;gt; become a coin project. It will return to its original purpose: a purely educational site for people who like to learn by tinkering and hands-on experimentation.&amp;lt;/p&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;h2&amp;gt;v4call.com&amp;lt;/h2&amp;gt;&lt;br /&gt;
    &amp;lt;p&amp;gt;→ &amp;lt;a href=&amp;quot;https://v4call.com&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;v4call.com&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;br /&gt;
    &amp;lt;p class=&amp;quot;status&amp;quot;&amp;gt;idk — tinker gonna tinker 😄&amp;lt;/p&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;hr&amp;gt;&lt;br /&gt;
    &amp;lt;p style=&amp;quot;text-align:center; color:#777; font-size:0.9em;&amp;quot;&amp;gt;&lt;br /&gt;
        Last updated: April 2026&lt;br /&gt;
    &amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;!-- pop up cookies and other  3/3--&amp;gt;&lt;br /&gt;
&amp;lt;script&amp;gt;&lt;br /&gt;
  function acceptAndHide() {&lt;br /&gt;
    // Save the choice in the browser&#039;s &amp;quot;localStorage&amp;quot; so it doesn&#039;t pop up again&lt;br /&gt;
    localStorage.setItem(&#039;gate_passed&#039;, &#039;true&#039;);&lt;br /&gt;
    document.getElementById(&#039;overlay&#039;).classList.add(&#039;hidden&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  // On page load, check if they already clicked &amp;quot;Yes&amp;quot;&lt;br /&gt;
  window.onload = function() {&lt;br /&gt;
    if (localStorage.getItem(&#039;gate_passed&#039;) === &#039;true&#039;) {&lt;br /&gt;
      document.getElementById(&#039;overlay&#039;).classList.add(&#039;hidden&#039;);&lt;br /&gt;
    }&lt;br /&gt;
  };&lt;br /&gt;
&amp;lt;/script&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/body&amp;gt;&lt;br /&gt;
&amp;lt;/html&amp;gt;&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Ubuntu_24.04_WebRTC_lxc_Intro_Basics&amp;diff=725</id>
		<title>Ubuntu 24.04 WebRTC lxc Intro Basics</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Ubuntu_24.04_WebRTC_lxc_Intro_Basics&amp;diff=725"/>
		<updated>2026-03-28T11:17:43Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;= WebRTC Video Chat on Ubuntu 24.04 with LXC — Complete Beginner&amp;#039;s Guide =  * This walk through is tested on a ubuntu-mate 24.04 host. ** Guide written with help from claude.ai for Ubuntu 24.04 LTS (Noble Numbat). Node.js 20 LTS. Last verified March 2026    This guide walks you through setting up a working browser-based WebRTC video/audio chat application. The signalling server will run inside an &amp;#039;&amp;#039;&amp;#039;LXC container&amp;#039;&amp;#039;&amp;#039; on your Ubuntu 24.04 host machine, and you will conne...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= WebRTC Video Chat on Ubuntu 24.04 with LXC — Complete Beginner&#039;s Guide =&lt;br /&gt;
&lt;br /&gt;
* This walk through is tested on a ubuntu-mate 24.04 host.&lt;br /&gt;
** Guide written with help from claude.ai for Ubuntu 24.04 LTS (Noble Numbat). Node.js 20 LTS. Last verified March 2026 &lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
This guide walks you through setting up a working browser-based WebRTC video/audio chat application. The signalling server will run inside an &#039;&#039;&#039;LXC container&#039;&#039;&#039; on your Ubuntu 24.04 host machine, and you will connect to it from your browser.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
== What You Will End Up With ==&lt;br /&gt;
&lt;br /&gt;
* An LXC container running Ubuntu 24.04&lt;br /&gt;
* A Node.js signalling server inside that container&lt;br /&gt;
* A static HTML/JS frontend served from the container&lt;br /&gt;
* Two browser tabs (or two devices on your network) that can video/audio chat peer-to-peer&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
== How It Works (Plain English) ==&lt;br /&gt;
&lt;br /&gt;
WebRTC lets two browsers talk directly to each other for audio and video. But before they can do that, they need to &#039;&#039;&#039;find each other&#039;&#039;&#039; and &#039;&#039;&#039;agree on connection details&#039;&#039;&#039;. This is called &#039;&#039;&#039;signalling&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Think of it like two people wanting to call each other — they first need to exchange phone numbers through a third party. The signalling server is that third party. Once the call is set up, the signalling server steps aside and the browsers talk directly.&lt;br /&gt;
&lt;br /&gt;
 [Browser A] &amp;lt;--WebSocket--&amp;gt; [Signalling Server in LXC] &amp;lt;--WebSocket--&amp;gt; [Browser B]&lt;br /&gt;
                                        |&lt;br /&gt;
                             (only used during setup)&lt;br /&gt;
                                        |&lt;br /&gt;
 [Browser A] &amp;lt;============ WebRTC peer-to-peer audio/video ============&amp;gt; [Browser B]&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 1: Set Up the LXC Container ==&lt;br /&gt;
&lt;br /&gt;
=== Step 1.1 — Install LXC on Your Host ===&lt;br /&gt;
&lt;br /&gt;
Open a terminal on your Ubuntu 24.04 host and run:&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;b&amp;gt;NOTE: you can also install lxd with snap packages on ubuntu.&amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
 sudo apt update&lt;br /&gt;
 sudo apt install lxc lxc-utils -y&lt;br /&gt;
&lt;br /&gt;
Check it installed correctly:&lt;br /&gt;
&lt;br /&gt;
 lxc-checkconfig&lt;br /&gt;
&lt;br /&gt;
You should see mostly &#039;&#039;&#039;enabled&#039;&#039;&#039; next to each item. A few &#039;&#039;&#039;missing&#039;&#039;&#039; items are normal and will not affect this guide.&lt;br /&gt;
&lt;br /&gt;
=== Step 1.2 — Create the Container ===&lt;br /&gt;
&lt;br /&gt;
Create a new Ubuntu 24.04 container called &amp;lt;code&amp;gt;webrtc&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
 sudo lxc-create -n webrtc -t download -- -d ubuntu -r noble -a amd64&lt;br /&gt;
&lt;br /&gt;
This downloads the Ubuntu 24.04 (Noble) image. It may take a minute or two.&lt;br /&gt;
&lt;br /&gt;
=== Step 1.3 — Start the Container ===&lt;br /&gt;
&lt;br /&gt;
 sudo lxc-start -n webrtc&lt;br /&gt;
&lt;br /&gt;
Check it is running:&lt;br /&gt;
&lt;br /&gt;
 sudo lxc-ls --fancy&lt;br /&gt;
&lt;br /&gt;
You should see &amp;lt;code&amp;gt;webrtc&amp;lt;/code&amp;gt; listed with state &#039;&#039;&#039;RUNNING&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
=== Step 1.4 — Log Into the Container ===&lt;br /&gt;
&lt;br /&gt;
 sudo lxc-attach -n webrtc&lt;br /&gt;
&lt;br /&gt;
Your prompt will change — you are now &#039;&#039;&#039;inside&#039;&#039;&#039; the container. Everything from here until told otherwise runs inside the container.&lt;br /&gt;
&lt;br /&gt;
=== Step 1.5 — Update the Container ===&lt;br /&gt;
&lt;br /&gt;
 apt update &amp;amp;&amp;amp; apt upgrade -y&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 2: Install Node.js Inside the Container ==&lt;br /&gt;
&lt;br /&gt;
=== Step 2.1 — Install Node.js ===&lt;br /&gt;
&lt;br /&gt;
We will use the official NodeSource repository to get a recent version of Node.js:&lt;br /&gt;
&lt;br /&gt;
 apt install -y curl&lt;br /&gt;
 curl -fsSL https://deb.nodesource.com/setup_20.x | bash -&lt;br /&gt;
 apt install -y nodejs&lt;br /&gt;
&lt;br /&gt;
Check the versions installed:&lt;br /&gt;
&lt;br /&gt;
 node -v&lt;br /&gt;
 npm -v&lt;br /&gt;
&lt;br /&gt;
You should see something like &amp;lt;code&amp;gt;v20.x.x&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;10.x.x&amp;lt;/code&amp;gt;. Any version of Node 18 or higher is fine.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 3: Create the Signalling Server ==&lt;br /&gt;
&lt;br /&gt;
=== Step 3.1 — Create a Project Folder ===&lt;br /&gt;
&lt;br /&gt;
 mkdir /opt/webrtc&lt;br /&gt;
 cd /opt/webrtc&lt;br /&gt;
&lt;br /&gt;
=== Step 3.2 — Initialise the Node Project ===&lt;br /&gt;
&lt;br /&gt;
 npm init -y&lt;br /&gt;
&lt;br /&gt;
This creates a &amp;lt;code&amp;gt;package.json&amp;lt;/code&amp;gt; file. The &amp;lt;code&amp;gt;-y&amp;lt;/code&amp;gt; flag just accepts all the defaults.&lt;br /&gt;
&lt;br /&gt;
=== Step 3.3 — Install Dependencies ===&lt;br /&gt;
&lt;br /&gt;
We need two packages:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;express&#039;&#039;&#039; — a simple web server to serve the HTML frontend&lt;br /&gt;
* &#039;&#039;&#039;socket.io&#039;&#039;&#039; — handles WebSocket connections for signalling&lt;br /&gt;
&lt;br /&gt;
 npm install express socket.io&lt;br /&gt;
&lt;br /&gt;
=== Step 3.4 — Create the Server File ===&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;b&amp;gt;NOTE: &amp;lt;code&amp;gt;nano&amp;lt;/code&amp;gt; will need to be installed in the container with &amp;lt;code&amp;gt;apt install nano -y&amp;lt;/code&amp;gt;&amp;lt;/b&amp;gt;&lt;br /&gt;
Create the file:&lt;br /&gt;
&lt;br /&gt;
 nano server.js&lt;br /&gt;
&lt;br /&gt;
Paste in the following code exactly. Use &#039;&#039;&#039;Ctrl+Shift+V&#039;&#039;&#039; to paste in the terminal:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;javascript&amp;quot;&amp;gt;&lt;br /&gt;
const express = require(&#039;express&#039;);&lt;br /&gt;
const http = require(&#039;http&#039;);&lt;br /&gt;
const { Server } = require(&#039;socket.io&#039;);&lt;br /&gt;
const path = require(&#039;path&#039;);&lt;br /&gt;
&lt;br /&gt;
const app = express();&lt;br /&gt;
const server = http.createServer(app);&lt;br /&gt;
const io = new Server(server);&lt;br /&gt;
&lt;br /&gt;
// Serve the frontend HTML file&lt;br /&gt;
app.use(express.static(path.join(__dirname, &#039;public&#039;)));&lt;br /&gt;
&lt;br /&gt;
// Keep track of who is in each room&lt;br /&gt;
const rooms = {};&lt;br /&gt;
&lt;br /&gt;
io.on(&#039;connection&#039;, (socket) =&amp;gt; {&lt;br /&gt;
  console.log(&#039;A user connected:&#039;, socket.id);&lt;br /&gt;
&lt;br /&gt;
  // User wants to join a room&lt;br /&gt;
  socket.on(&#039;join&#039;, (room) =&amp;gt; {&lt;br /&gt;
    socket.join(room);&lt;br /&gt;
&lt;br /&gt;
    if (!rooms[room]) {&lt;br /&gt;
      rooms[room] = [];&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    // Tell the new user who is already in the room&lt;br /&gt;
    socket.emit(&#039;room-users&#039;, rooms[room]);&lt;br /&gt;
&lt;br /&gt;
    // Add this user to the room list&lt;br /&gt;
    rooms[room].push(socket.id);&lt;br /&gt;
&lt;br /&gt;
    console.log(`${socket.id} joined room: ${room}`);&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Relay a WebRTC offer to a specific user&lt;br /&gt;
  socket.on(&#039;offer&#039;, ({ to, offer }) =&amp;gt; {&lt;br /&gt;
    io.to(to).emit(&#039;offer&#039;, { from: socket.id, offer });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Relay a WebRTC answer to a specific user&lt;br /&gt;
  socket.on(&#039;answer&#039;, ({ to, answer }) =&amp;gt; {&lt;br /&gt;
    io.to(to).emit(&#039;answer&#039;, { from: socket.id, answer });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Relay ICE candidates between peers&lt;br /&gt;
  socket.on(&#039;ice-candidate&#039;, ({ to, candidate }) =&amp;gt; {&lt;br /&gt;
    io.to(to).emit(&#039;ice-candidate&#039;, { from: socket.id, candidate });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Clean up when a user disconnects&lt;br /&gt;
  socket.on(&#039;disconnect&#039;, () =&amp;gt; {&lt;br /&gt;
    for (const room in rooms) {&lt;br /&gt;
      rooms[room] = rooms[room].filter((id) =&amp;gt; id !== socket.id);&lt;br /&gt;
      // Tell others in the room this user left&lt;br /&gt;
      socket.to(room).emit(&#039;user-left&#039;, socket.id);&lt;br /&gt;
    }&lt;br /&gt;
    console.log(&#039;User disconnected:&#039;, socket.id);&lt;br /&gt;
  });&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
const PORT = 3000;&lt;br /&gt;
server.listen(PORT, &#039;0.0.0.0&#039;, () =&amp;gt; {&lt;br /&gt;
  console.log(`Signalling server running on port ${PORT}`);&lt;br /&gt;
});&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Save and exit: press &#039;&#039;&#039;Ctrl+X&#039;&#039;&#039;, then &#039;&#039;&#039;Y&#039;&#039;&#039;, then &#039;&#039;&#039;Enter&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
=== Step 3.5 — Create the Frontend ===&lt;br /&gt;
&lt;br /&gt;
Create a folder for the HTML file:&lt;br /&gt;
&lt;br /&gt;
 mkdir public&lt;br /&gt;
 nano public/index.html&lt;br /&gt;
&lt;br /&gt;
Paste in the following:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;html&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br /&gt;
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;head&amp;gt;&lt;br /&gt;
  &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot; /&amp;gt;&lt;br /&gt;
  &amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1.0&amp;quot;/&amp;gt;&lt;br /&gt;
  &amp;lt;title&amp;gt;WebRTC Chat&amp;lt;/title&amp;gt;&lt;br /&gt;
  &amp;lt;style&amp;gt;&lt;br /&gt;
    * { box-sizing: border-box; margin: 0; padding: 0; }&lt;br /&gt;
    body { font-family: sans-serif; background: #1a1a2e; color: #eee; display: flex; flex-direction: column; align-items: center; padding: 20px; }&lt;br /&gt;
    h1 { margin-bottom: 20px; color: #e94560; }&lt;br /&gt;
    #join-area { margin-bottom: 20px; display: flex; gap: 10px; }&lt;br /&gt;
    input { padding: 10px; border-radius: 6px; border: none; font-size: 1rem; width: 200px; }&lt;br /&gt;
    button { padding: 10px 20px; background: #e94560; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1rem; }&lt;br /&gt;
    button:hover { background: #c73652; }&lt;br /&gt;
    #videos { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }&lt;br /&gt;
    video { width: 320px; height: 240px; background: #000; border-radius: 8px; border: 2px solid #e94560; }&lt;br /&gt;
    #status { margin-top: 15px; font-size: 0.9rem; color: #aaa; }&lt;br /&gt;
  &amp;lt;/style&amp;gt;&lt;br /&gt;
&amp;lt;/head&amp;gt;&lt;br /&gt;
&amp;lt;body&amp;gt;&lt;br /&gt;
  &amp;lt;h1&amp;gt;WebRTC Chat&amp;lt;/h1&amp;gt;&lt;br /&gt;
  &amp;lt;div id=&amp;quot;join-area&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;input id=&amp;quot;room-input&amp;quot; type=&amp;quot;text&amp;quot; placeholder=&amp;quot;Enter room name&amp;quot; value=&amp;quot;room1&amp;quot; /&amp;gt;&lt;br /&gt;
    &amp;lt;button onclick=&amp;quot;joinRoom()&amp;quot;&amp;gt;Join Room&amp;lt;/button&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div id=&amp;quot;videos&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;video id=&amp;quot;local-video&amp;quot; autoplay muted playsinline&amp;gt;&amp;lt;/video&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;p id=&amp;quot;status&amp;quot;&amp;gt;Enter a room name and click Join.&amp;lt;/p&amp;gt;&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;!-- Socket.io client is served automatically by the server --&amp;gt;&lt;br /&gt;
  &amp;lt;script src=&amp;quot;/socket.io/socket.io.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;&lt;br /&gt;
  &amp;lt;script&amp;gt;&lt;br /&gt;
    const socket = io();&lt;br /&gt;
    let localStream;&lt;br /&gt;
    const peers = {}; // peerId -&amp;gt; RTCPeerConnection&lt;br /&gt;
&lt;br /&gt;
    // STUN server config — Google&#039;s free public STUN server&lt;br /&gt;
    const iceConfig = {&lt;br /&gt;
      iceServers: [{ urls: &#039;stun:stun.l.google.com:19302&#039; }]&lt;br /&gt;
    };&lt;br /&gt;
&lt;br /&gt;
    async function joinRoom() {&lt;br /&gt;
      const room = document.getElementById(&#039;room-input&#039;).value.trim();&lt;br /&gt;
      if (!room) return alert(&#039;Please enter a room name&#039;);&lt;br /&gt;
&lt;br /&gt;
      document.getElementById(&#039;status&#039;).textContent = &#039;Getting camera and microphone...&#039;;&lt;br /&gt;
&lt;br /&gt;
      try {&lt;br /&gt;
        localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });&lt;br /&gt;
        document.getElementById(&#039;local-video&#039;).srcObject = localStream;&lt;br /&gt;
      } catch (err) {&lt;br /&gt;
        alert(&#039;Could not access camera/microphone: &#039; + err.message);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      document.getElementById(&#039;status&#039;).textContent = `Joining room: ${room}`;&lt;br /&gt;
      socket.emit(&#039;join&#039;, room);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    // Server tells us who is already in the room&lt;br /&gt;
    socket.on(&#039;room-users&#039;, async (users) =&amp;gt; {&lt;br /&gt;
      document.getElementById(&#039;status&#039;).textContent = `In room. ${users.length} other(s) here.`;&lt;br /&gt;
      // Send an offer to each existing user&lt;br /&gt;
      for (const userId of users) {&lt;br /&gt;
        await createOffer(userId);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    // A remote peer sent us an offer&lt;br /&gt;
    socket.on(&#039;offer&#039;, async ({ from, offer }) =&amp;gt; {&lt;br /&gt;
      const pc = createPeerConnection(from);&lt;br /&gt;
      await pc.setRemoteDescription(new RTCSessionDescription(offer));&lt;br /&gt;
      const answer = await pc.createAnswer();&lt;br /&gt;
      await pc.setLocalDescription(answer);&lt;br /&gt;
      socket.emit(&#039;answer&#039;, { to: from, answer });&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    // A remote peer accepted our offer&lt;br /&gt;
    socket.on(&#039;answer&#039;, async ({ from, answer }) =&amp;gt; {&lt;br /&gt;
      const pc = peers[from];&lt;br /&gt;
      if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    // ICE candidate from a remote peer&lt;br /&gt;
    socket.on(&#039;ice-candidate&#039;, async ({ from, candidate }) =&amp;gt; {&lt;br /&gt;
      const pc = peers[from];&lt;br /&gt;
      if (pc &amp;amp;&amp;amp; candidate) {&lt;br /&gt;
        try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) {}&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    // A user left the room&lt;br /&gt;
    socket.on(&#039;user-left&#039;, (userId) =&amp;gt; {&lt;br /&gt;
      if (peers[userId]) {&lt;br /&gt;
        peers[userId].close();&lt;br /&gt;
        delete peers[userId];&lt;br /&gt;
      }&lt;br /&gt;
      const el = document.getElementById(&#039;video-&#039; + userId);&lt;br /&gt;
      if (el) el.remove();&lt;br /&gt;
      document.getElementById(&#039;status&#039;).textContent = &#039;A user left the room.&#039;;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    function createPeerConnection(peerId) {&lt;br /&gt;
      const pc = new RTCPeerConnection(iceConfig);&lt;br /&gt;
      peers[peerId] = pc;&lt;br /&gt;
&lt;br /&gt;
      // Add our local tracks so the remote peer gets our video/audio&lt;br /&gt;
      localStream.getTracks().forEach((track) =&amp;gt; pc.addTrack(track, localStream));&lt;br /&gt;
&lt;br /&gt;
      // When we get an ICE candidate, send it to the remote peer&lt;br /&gt;
      pc.onicecandidate = ({ candidate }) =&amp;gt; {&lt;br /&gt;
        if (candidate) socket.emit(&#039;ice-candidate&#039;, { to: peerId, candidate });&lt;br /&gt;
      };&lt;br /&gt;
&lt;br /&gt;
      // When we receive a remote track, display it&lt;br /&gt;
      pc.ontrack = ({ streams }) =&amp;gt; {&lt;br /&gt;
        let videoEl = document.getElementById(&#039;video-&#039; + peerId);&lt;br /&gt;
        if (!videoEl) {&lt;br /&gt;
          videoEl = document.createElement(&#039;video&#039;);&lt;br /&gt;
          videoEl.id = &#039;video-&#039; + peerId;&lt;br /&gt;
          videoEl.autoplay = true;&lt;br /&gt;
          videoEl.playsInline = true;&lt;br /&gt;
          document.getElementById(&#039;videos&#039;).appendChild(videoEl);&lt;br /&gt;
        }&lt;br /&gt;
        videoEl.srcObject = streams[0];&lt;br /&gt;
      };&lt;br /&gt;
&lt;br /&gt;
      return pc;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    async function createOffer(peerId) {&lt;br /&gt;
      const pc = createPeerConnection(peerId);&lt;br /&gt;
      const offer = await pc.createOffer();&lt;br /&gt;
      await pc.setLocalDescription(offer);&lt;br /&gt;
      socket.emit(&#039;offer&#039;, { to: peerId, offer });&lt;br /&gt;
    }&lt;br /&gt;
  &amp;lt;/script&amp;gt;&lt;br /&gt;
&amp;lt;/body&amp;gt;&lt;br /&gt;
&amp;lt;/html&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Save and exit: &#039;&#039;&#039;Ctrl+X&#039;&#039;&#039;, &#039;&#039;&#039;Y&#039;&#039;&#039;, &#039;&#039;&#039;Enter&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 4: Run the Server ==&lt;br /&gt;
&lt;br /&gt;
=== Step 4.1 — Test it Manually First ===&lt;br /&gt;
&lt;br /&gt;
Inside the container, run:&lt;br /&gt;
&lt;br /&gt;
 node /opt/webrtc/server.js&lt;br /&gt;
&lt;br /&gt;
You should see:&lt;br /&gt;
&lt;br /&gt;
 Signalling server running on port 3000&lt;br /&gt;
&lt;br /&gt;
Press &#039;&#039;&#039;Ctrl+C&#039;&#039;&#039; to stop it for now. We will set it up to run automatically in the next step.&lt;br /&gt;
&lt;br /&gt;
=== Step 4.2 — Find the Container&#039;s IP Address ===&lt;br /&gt;
&lt;br /&gt;
You need this to access the server from your host browser. Open a &#039;&#039;&#039;second terminal on your host&#039;&#039;&#039; (not inside the container) and run:&lt;br /&gt;
&lt;br /&gt;
 sudo lxc-ls --fancy&lt;br /&gt;
&lt;br /&gt;
Look for your &amp;lt;code&amp;gt;webrtc&amp;lt;/code&amp;gt; container and note the IP address in the &#039;&#039;&#039;IPV4&#039;&#039;&#039; column. It will look something like &amp;lt;code&amp;gt;10.0.3.15&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
=== Step 4.3 — Set Up Auto-start with systemd ===&lt;br /&gt;
&lt;br /&gt;
We want the server to start automatically when the container boots. Back inside the container, create a systemd service file:&lt;br /&gt;
&lt;br /&gt;
 nano /etc/systemd/system/webrtc.service&lt;br /&gt;
&lt;br /&gt;
Paste in:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;ini&amp;quot;&amp;gt;&lt;br /&gt;
[Unit]&lt;br /&gt;
Description=WebRTC Signalling Server&lt;br /&gt;
After=network.target&lt;br /&gt;
&lt;br /&gt;
[Service]&lt;br /&gt;
Type=simple&lt;br /&gt;
WorkingDirectory=/opt/webrtc&lt;br /&gt;
ExecStart=/usr/bin/node /opt/webrtc/server.js&lt;br /&gt;
Restart=on-failure&lt;br /&gt;
RestartSec=5&lt;br /&gt;
&lt;br /&gt;
[Install]&lt;br /&gt;
WantedBy=multi-user.target&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Save and exit. Then enable and start it:&lt;br /&gt;
&lt;br /&gt;
 systemctl daemon-reload&lt;br /&gt;
 systemctl enable webrtc&lt;br /&gt;
 systemctl start webrtc&lt;br /&gt;
&lt;br /&gt;
Check it is running:&lt;br /&gt;
&lt;br /&gt;
 systemctl status webrtc&lt;br /&gt;
&lt;br /&gt;
You should see &#039;&#039;&#039;active (running)&#039;&#039;&#039; in green.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 5: Configure the LXC Container to Auto-start ==&lt;br /&gt;
&lt;br /&gt;
We also want the container itself to start when your host boots.&lt;br /&gt;
&lt;br /&gt;
Exit the container first (type &amp;lt;code&amp;gt;exit&amp;lt;/code&amp;gt; or press &#039;&#039;&#039;Ctrl+D&#039;&#039;&#039; to return to your host terminal), then run:&lt;br /&gt;
&lt;br /&gt;
 sudo nano /var/lib/lxc/webrtc/config&lt;br /&gt;
&lt;br /&gt;
Add these two lines at the bottom of the file:&lt;br /&gt;
&lt;br /&gt;
 lxc.start.auto = 1&lt;br /&gt;
 lxc.start.delay = 5&lt;br /&gt;
&lt;br /&gt;
Save and exit. Now the container (and the signalling server inside it) will start automatically on boot.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 6: Open the App in Your Browser ==&lt;br /&gt;
&lt;br /&gt;
=== Step 6.1 — Open the App ===&lt;br /&gt;
&lt;br /&gt;
On your host machine, open a browser and go to:&lt;br /&gt;
&lt;br /&gt;
 http://&amp;lt;container-ip&amp;gt;:3000&lt;br /&gt;
&lt;br /&gt;
Replace &amp;lt;code&amp;gt;&amp;lt;container-ip&amp;gt;&amp;lt;/code&amp;gt; with the IP address you noted in Step 4.2. For example:&lt;br /&gt;
&lt;br /&gt;
 http://10.0.3.15:3000&lt;br /&gt;
&lt;br /&gt;
You should see the WebRTC Chat page.&lt;br /&gt;
&lt;br /&gt;
=== Step 6.2 — Test With Two Browser Tabs ===&lt;br /&gt;
&lt;br /&gt;
# Open the app in &#039;&#039;&#039;Tab 1&#039;&#039;&#039;. Type a room name (e.g. &amp;lt;code&amp;gt;room1&amp;lt;/code&amp;gt;) and click &#039;&#039;&#039;Join Room&#039;&#039;&#039;. Allow camera and microphone access when the browser asks.&lt;br /&gt;
# Open the app in &#039;&#039;&#039;Tab 2&#039;&#039;&#039; (you can use a private/incognito window to get a separate camera stream). Type the &#039;&#039;&#039;same room name&#039;&#039;&#039; and click &#039;&#039;&#039;Join Room&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
The two tabs should now connect and you will see two video feeds.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
NOTE:If you see &amp;lt;code&amp;gt;Could not access camera/microphone: Cannot read properties of undefined (reading &#039;getUserMedia&#039;)&amp;lt;/code&amp;gt; &amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;b&amp;gt;NOTE: if you see &amp;lt;code&amp;gt;Could not access camera/microphone: Cannot read properties of undefined (reading &#039;getUserMedia&#039;)&amp;lt;/code&amp;gt; Then you need to go to step 7 and setup HTTPS&amp;lt;/b&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is the HTTPS issue mentioned in Part 7. Browsers block camera/microphone access on plain http:// from non-localhost addresses — navigator.mediaDevices is simply undefined on insecure origins.&lt;br /&gt;
&lt;br /&gt;
Since you&#039;re accessing via the container&#039;s IP (e.g. http://10.0.3.15:3000), the browser sees it as insecure and disables the API entirely.&lt;br /&gt;
&lt;br /&gt;
Quickest fix — use mkcert on your host:While you choose — here&#039;s a quick workaround you can do right now with zero config, if you just want to test immediately:&lt;br /&gt;
&lt;br /&gt;
Option: Chrome flag to allow insecure origins&lt;br /&gt;
&lt;br /&gt;
Open Chrome and go to: chrome://flags/#unsafely-treat-insecure-origin-as-secure&lt;br /&gt;
&lt;br /&gt;
Paste your container URL (e.g. http://10.0.3.15:3000) into the text box and enable it&lt;br /&gt;
&lt;br /&gt;
Relaunch Chrome when prompted&lt;br /&gt;
&lt;br /&gt;
This tells Chrome to treat that specific IP as secure, enabling getUserMedia. Only use this for local dev/testing — never on a production machine.&lt;br /&gt;
Firefox equivalent: go to about:config, search for media.devices.insecure.enabled and set it to true.&lt;br /&gt;
Once you pick your preferred HTTPS method above I&#039;ll give you the exact step-by-step commands to do it properly inside your LXC container.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Step 6.3 — Test From Another Device on Your Network ===&lt;br /&gt;
&lt;br /&gt;
On any other device connected to the same Wi-Fi or LAN, open a browser and go to the same URL:&lt;br /&gt;
&lt;br /&gt;
 http://&amp;lt;container-ip&amp;gt;:3000&lt;br /&gt;
&lt;br /&gt;
Join the same room name and you should connect peer-to-peer with the other browser.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Note:&#039;&#039;&#039; Camera and microphone access in browsers requires either &amp;lt;code&amp;gt;localhost&amp;lt;/code&amp;gt; or a secure HTTPS connection. For testing on the same machine, &amp;lt;code&amp;gt;http://localhost&amp;lt;/code&amp;gt; works fine. For other devices on your network, you may need to set up HTTPS (see Part 7).&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 7: (Optional) Enable HTTPS for Other Devices ==&lt;br /&gt;
&lt;br /&gt;
Browsers block camera/microphone access on plain HTTP from non-localhost addresses. To use this from other devices on your network, you have two options:&lt;br /&gt;
&lt;br /&gt;
=== Option A — Use a Self-Signed Certificate (Quick) ===&lt;br /&gt;
&lt;br /&gt;
Inside the container:&lt;br /&gt;
&lt;br /&gt;
 apt install -y openssl&lt;br /&gt;
 cd /opt/webrtc&lt;br /&gt;
 openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj &amp;quot;/CN=localhost&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Edit &amp;lt;code&amp;gt;server.js&amp;lt;/code&amp;gt; and replace the &amp;lt;code&amp;gt;http&amp;lt;/code&amp;gt; section:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;javascript&amp;quot;&amp;gt;&lt;br /&gt;
const https = require(&#039;https&#039;);&lt;br /&gt;
const fs = require(&#039;fs&#039;);&lt;br /&gt;
&lt;br /&gt;
const server = https.createServer({&lt;br /&gt;
  key: fs.readFileSync(&#039;/opt/webrtc/key.pem&#039;),&lt;br /&gt;
  cert: fs.readFileSync(&#039;/opt/webrtc/cert.pem&#039;),&lt;br /&gt;
}, app);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Appended &amp;lt;code&amp;gt;server.js&amp;lt;/code&amp;gt; file:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
const express = require(&#039;express&#039;);&lt;br /&gt;
const https = require(&#039;https&#039;);&lt;br /&gt;
const { Server } = require(&#039;socket.io&#039;);&lt;br /&gt;
const path = require(&#039;path&#039;);&lt;br /&gt;
&lt;br /&gt;
const app = express();&lt;br /&gt;
const fs = require(&#039;fs&#039;);&lt;br /&gt;
&lt;br /&gt;
const server = https.createServer({&lt;br /&gt;
  key: fs.readFileSync(&#039;/opt/webrtc/key.pem&#039;),&lt;br /&gt;
  cert: fs.readFileSync(&#039;/opt/webrtc/cert.pem&#039;),&lt;br /&gt;
}, app);&lt;br /&gt;
const io = new Server(server);&lt;br /&gt;
&lt;br /&gt;
// Serve the frontend HTML file&lt;br /&gt;
app.use(express.static(path.join(__dirname, &#039;public&#039;)));&lt;br /&gt;
&lt;br /&gt;
// Keep track of who is in each room&lt;br /&gt;
const rooms = {};&lt;br /&gt;
&lt;br /&gt;
io.on(&#039;connection&#039;, (socket) =&amp;gt; {&lt;br /&gt;
  console.log(&#039;A user connected:&#039;, socket.id);&lt;br /&gt;
&lt;br /&gt;
  // User wants to join a room&lt;br /&gt;
  socket.on(&#039;join&#039;, (room) =&amp;gt; {&lt;br /&gt;
    socket.join(room);&lt;br /&gt;
&lt;br /&gt;
    if (!rooms[room]) {&lt;br /&gt;
      rooms[room] = [];&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    // Tell the new user who is already in the room&lt;br /&gt;
    socket.emit(&#039;room-users&#039;, rooms[room]);&lt;br /&gt;
&lt;br /&gt;
    // Add this user to the room list&lt;br /&gt;
    rooms[room].push(socket.id);&lt;br /&gt;
&lt;br /&gt;
    console.log(`${socket.id} joined room: ${room}`);&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Relay a WebRTC offer to a specific user&lt;br /&gt;
  socket.on(&#039;offer&#039;, ({ to, offer }) =&amp;gt; {&lt;br /&gt;
    io.to(to).emit(&#039;offer&#039;, { from: socket.id, offer });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Relay a WebRTC answer to a specific user&lt;br /&gt;
  socket.on(&#039;answer&#039;, ({ to, answer }) =&amp;gt; {&lt;br /&gt;
    io.to(to).emit(&#039;answer&#039;, { from: socket.id, answer });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Relay ICE candidates between peers&lt;br /&gt;
  socket.on(&#039;ice-candidate&#039;, ({ to, candidate }) =&amp;gt; {&lt;br /&gt;
    io.to(to).emit(&#039;ice-candidate&#039;, { from: socket.id, candidate });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Clean up when a user disconnects&lt;br /&gt;
  socket.on(&#039;disconnect&#039;, () =&amp;gt; {&lt;br /&gt;
    for (const room in rooms) {&lt;br /&gt;
      rooms[room] = rooms[room].filter((id) =&amp;gt; id !== socket.id);&lt;br /&gt;
      // Tell others in the room this user left&lt;br /&gt;
      socket.to(room).emit(&#039;user-left&#039;, socket.id);&lt;br /&gt;
    }&lt;br /&gt;
    console.log(&#039;User disconnected:&#039;, socket.id);&lt;br /&gt;
  });&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
const PORT = 3000;&lt;br /&gt;
server.listen(PORT, &#039;0.0.0.0&#039;, () =&amp;gt; {&lt;br /&gt;
  console.log(`Signalling server running on port ${PORT}`);&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Change &amp;lt;code&amp;gt;const http = require(&#039;http&#039;);&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;const server = http.createServer(app);&amp;lt;/code&amp;gt; lines accordingly.&lt;br /&gt;
&lt;br /&gt;
Restart the service:&lt;br /&gt;
&lt;br /&gt;
 systemctl restart webrtc&lt;br /&gt;
&lt;br /&gt;
Access via:&lt;br /&gt;
&lt;br /&gt;
 https://&amp;lt;container-ip&amp;gt;:3000&lt;br /&gt;
&lt;br /&gt;
Your browser will warn about the self-signed certificate — click &#039;&#039;&#039;Advanced&#039;&#039;&#039; and &#039;&#039;&#039;Proceed&#039;&#039;&#039; to continue.&lt;br /&gt;
&lt;br /&gt;
=== Option B — Use mkcert (Trusted Certificate, Recommended) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;mkcert&amp;lt;/code&amp;gt; creates locally trusted certificates without browser warnings.&lt;br /&gt;
&lt;br /&gt;
On your &#039;&#039;&#039;host machine&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
 sudo apt install mkcert&lt;br /&gt;
 mkcert -install&lt;br /&gt;
 mkcert &amp;lt;container-ip&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This creates &amp;lt;code&amp;gt;&amp;lt;container-ip&amp;gt;.pem&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;&amp;lt;container-ip&amp;gt;-key.pem&amp;lt;/code&amp;gt;. Copy them into the container and follow the same server.js edits as Option A.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 8: Useful Commands Reference ==&lt;br /&gt;
&lt;br /&gt;
=== Managing the Container ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Command !! What it does&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;sudo lxc-start -n webrtc&amp;lt;/code&amp;gt; || Start the container&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;sudo lxc-stop -n webrtc&amp;lt;/code&amp;gt; || Stop the container&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;sudo lxc-attach -n webrtc&amp;lt;/code&amp;gt; || Open a shell inside the container&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;sudo lxc-ls --fancy&amp;lt;/code&amp;gt; || List containers and their status/IP&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Managing the Server (run inside the container) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Command !! What it does&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;systemctl status webrtc&amp;lt;/code&amp;gt; || Check if the server is running&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;systemctl restart webrtc&amp;lt;/code&amp;gt; || Restart the server&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;systemctl stop webrtc&amp;lt;/code&amp;gt; || Stop the server&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;journalctl -u webrtc -f&amp;lt;/code&amp;gt; || Watch live server logs&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Adding a ChatBox ==&lt;br /&gt;
&lt;br /&gt;
* Added: &lt;br /&gt;
** A name input on the join screen so people aren&#039;t just &amp;quot;Anonymous&amp;quot;&lt;br /&gt;
** A chat panel on the right with message bubbles (your messages on the right, others on the left)&lt;br /&gt;
** Automatic link detection — any URL typed becomes a clickable link&lt;br /&gt;
** Enter to send, Shift+Enter for a newline&lt;br /&gt;
** A 500 character counter that turns red when you&#039;re close to the limit&lt;br /&gt;
** System messages when users join/leave&lt;br /&gt;
** A live peer count header showing how many others are in the room&lt;br /&gt;
** The textarea auto-resizes as you type&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
* Edit &amp;lt;code&amp;gt;public/index.html&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Appended &amp;lt;code&amp;gt;public/index.html&amp;lt;/code&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br /&gt;
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;head&amp;gt;&lt;br /&gt;
  &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot; /&amp;gt;&lt;br /&gt;
  &amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1.0&amp;quot;/&amp;gt;&lt;br /&gt;
  &amp;lt;title&amp;gt;WebRTC Chat&amp;lt;/title&amp;gt;&lt;br /&gt;
  &amp;lt;link rel=&amp;quot;preconnect&amp;quot; href=&amp;quot;https://fonts.googleapis.com&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;link href=&amp;quot;https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&amp;amp;family=IBM+Plex+Sans:wght@400;500;600&amp;amp;display=swap&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;style&amp;gt;&lt;br /&gt;
    :root {&lt;br /&gt;
      --bg:       #0d0f14;&lt;br /&gt;
      --surface:  #161920;&lt;br /&gt;
      --border:   #252932;&lt;br /&gt;
      --accent:   #4ade80;&lt;br /&gt;
      --accent2:  #22d3ee;&lt;br /&gt;
      --muted:    #4b5263;&lt;br /&gt;
      --text:     #e2e8f0;&lt;br /&gt;
      --subtext:  #8892a4;&lt;br /&gt;
      --self-msg: #1a2e22;&lt;br /&gt;
      --other-msg:#161d2e;&lt;br /&gt;
      --danger:   #f87171;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    * { box-sizing: border-box; margin: 0; padding: 0; }&lt;br /&gt;
&lt;br /&gt;
    body {&lt;br /&gt;
      font-family: &#039;IBM Plex Sans&#039;, sans-serif;&lt;br /&gt;
      background: var(--bg);&lt;br /&gt;
      color: var(--text);&lt;br /&gt;
      height: 100dvh;&lt;br /&gt;
      display: flex;&lt;br /&gt;
      flex-direction: column;&lt;br /&gt;
      overflow: hidden;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    /* ── Top bar ── */&lt;br /&gt;
    header {&lt;br /&gt;
      display: flex;&lt;br /&gt;
      align-items: center;&lt;br /&gt;
      gap: 12px;&lt;br /&gt;
      padding: 12px 20px;&lt;br /&gt;
      border-bottom: 1px solid var(--border);&lt;br /&gt;
      background: var(--surface);&lt;br /&gt;
      flex-shrink: 0;&lt;br /&gt;
    }&lt;br /&gt;
    header h1 {&lt;br /&gt;
      font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
      font-size: 1rem;&lt;br /&gt;
      color: var(--accent);&lt;br /&gt;
      letter-spacing: 0.05em;&lt;br /&gt;
    }&lt;br /&gt;
    #room-badge {&lt;br /&gt;
      font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
      font-size: 0.75rem;&lt;br /&gt;
      color: var(--subtext);&lt;br /&gt;
      background: var(--border);&lt;br /&gt;
      padding: 2px 10px;&lt;br /&gt;
      border-radius: 999px;&lt;br /&gt;
      display: none;&lt;br /&gt;
    }&lt;br /&gt;
    #status-dot {&lt;br /&gt;
      width: 8px; height: 8px;&lt;br /&gt;
      border-radius: 50%;&lt;br /&gt;
      background: var(--muted);&lt;br /&gt;
      margin-left: auto;&lt;br /&gt;
      flex-shrink: 0;&lt;br /&gt;
      transition: background 0.3s;&lt;br /&gt;
    }&lt;br /&gt;
    #status-dot.live { background: var(--accent); box-shadow: 0 0 6px var(--accent); }&lt;br /&gt;
&lt;br /&gt;
    /* ── Main layout ── */&lt;br /&gt;
    main {&lt;br /&gt;
      display: flex;&lt;br /&gt;
      flex: 1;&lt;br /&gt;
      overflow: hidden;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    /* ── Join screen ── */&lt;br /&gt;
    #join-screen {&lt;br /&gt;
      display: flex;&lt;br /&gt;
      flex-direction: column;&lt;br /&gt;
      align-items: center;&lt;br /&gt;
      justify-content: center;&lt;br /&gt;
      gap: 16px;&lt;br /&gt;
      flex: 1;&lt;br /&gt;
      padding: 40px 20px;&lt;br /&gt;
    }&lt;br /&gt;
    #join-screen h2 {&lt;br /&gt;
      font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
      font-size: 1.4rem;&lt;br /&gt;
      color: var(--accent);&lt;br /&gt;
    }&lt;br /&gt;
    #join-screen p { color: var(--subtext); font-size: 0.9rem; text-align: center; max-width: 340px; }&lt;br /&gt;
    .field-group {&lt;br /&gt;
      display: flex;&lt;br /&gt;
      flex-direction: column;&lt;br /&gt;
      gap: 8px;&lt;br /&gt;
      width: 100%;&lt;br /&gt;
      max-width: 320px;&lt;br /&gt;
    }&lt;br /&gt;
    .field-group label { font-size: 0.8rem; color: var(--subtext); font-family: &#039;IBM Plex Mono&#039;, monospace; }&lt;br /&gt;
    input[type=&amp;quot;text&amp;quot;] {&lt;br /&gt;
      padding: 10px 14px;&lt;br /&gt;
      background: var(--surface);&lt;br /&gt;
      border: 1px solid var(--border);&lt;br /&gt;
      border-radius: 6px;&lt;br /&gt;
      color: var(--text);&lt;br /&gt;
      font-family: &#039;IBM Plex Sans&#039;, sans-serif;&lt;br /&gt;
      font-size: 0.95rem;&lt;br /&gt;
      width: 100%;&lt;br /&gt;
      transition: border-color 0.2s;&lt;br /&gt;
      outline: none;&lt;br /&gt;
    }&lt;br /&gt;
    input[type=&amp;quot;text&amp;quot;]:focus { border-color: var(--accent); }&lt;br /&gt;
    button.primary {&lt;br /&gt;
      padding: 11px 24px;&lt;br /&gt;
      background: var(--accent);&lt;br /&gt;
      color: #0d0f14;&lt;br /&gt;
      border: none;&lt;br /&gt;
      border-radius: 6px;&lt;br /&gt;
      cursor: pointer;&lt;br /&gt;
      font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
      font-weight: 600;&lt;br /&gt;
      font-size: 0.9rem;&lt;br /&gt;
      width: 100%;&lt;br /&gt;
      max-width: 320px;&lt;br /&gt;
      transition: opacity 0.2s, transform 0.1s;&lt;br /&gt;
    }&lt;br /&gt;
    button.primary:hover { opacity: 0.85; }&lt;br /&gt;
    button.primary:active { transform: scale(0.98); }&lt;br /&gt;
&lt;br /&gt;
    /* ── App layout (after join) ── */&lt;br /&gt;
    #app { display: none; flex: 1; overflow: hidden; }&lt;br /&gt;
    #app.visible { display: flex; }&lt;br /&gt;
&lt;br /&gt;
    /* ── Video panel ── */&lt;br /&gt;
    #video-panel {&lt;br /&gt;
      display: flex;&lt;br /&gt;
      flex-direction: column;&lt;br /&gt;
      gap: 0;&lt;br /&gt;
      background: #0a0c10;&lt;br /&gt;
      border-right: 1px solid var(--border);&lt;br /&gt;
      overflow-y: auto;&lt;br /&gt;
      flex-shrink: 0;&lt;br /&gt;
      width: 340px;&lt;br /&gt;
    }&lt;br /&gt;
    @media (max-width: 700px) {&lt;br /&gt;
      #app.visible { flex-direction: column; }&lt;br /&gt;
      #video-panel { width: 100%; max-height: 220px; flex-direction: row; overflow-x: auto; overflow-y: hidden; border-right: none; border-bottom: 1px solid var(--border); }&lt;br /&gt;
    }&lt;br /&gt;
    .video-wrapper {&lt;br /&gt;
      position: relative;&lt;br /&gt;
      background: #000;&lt;br /&gt;
    }&lt;br /&gt;
    .video-wrapper video {&lt;br /&gt;
      width: 100%;&lt;br /&gt;
      display: block;&lt;br /&gt;
      aspect-ratio: 4/3;&lt;br /&gt;
      object-fit: cover;&lt;br /&gt;
    }&lt;br /&gt;
    .video-label {&lt;br /&gt;
      position: absolute;&lt;br /&gt;
      bottom: 6px; left: 8px;&lt;br /&gt;
      font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
      font-size: 0.7rem;&lt;br /&gt;
      color: #fff;&lt;br /&gt;
      background: rgba(0,0,0,0.55);&lt;br /&gt;
      padding: 2px 7px;&lt;br /&gt;
      border-radius: 4px;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    /* ── Chat panel ── */&lt;br /&gt;
    #chat-panel {&lt;br /&gt;
      display: flex;&lt;br /&gt;
      flex-direction: column;&lt;br /&gt;
      flex: 1;&lt;br /&gt;
      overflow: hidden;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    #chat-header {&lt;br /&gt;
      padding: 10px 16px;&lt;br /&gt;
      border-bottom: 1px solid var(--border);&lt;br /&gt;
      font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
      font-size: 0.78rem;&lt;br /&gt;
      color: var(--subtext);&lt;br /&gt;
      background: var(--surface);&lt;br /&gt;
      flex-shrink: 0;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    #messages {&lt;br /&gt;
      flex: 1;&lt;br /&gt;
      overflow-y: auto;&lt;br /&gt;
      padding: 16px;&lt;br /&gt;
      display: flex;&lt;br /&gt;
      flex-direction: column;&lt;br /&gt;
      gap: 10px;&lt;br /&gt;
      scroll-behavior: smooth;&lt;br /&gt;
    }&lt;br /&gt;
    #messages::-webkit-scrollbar { width: 4px; }&lt;br /&gt;
    #messages::-webkit-scrollbar-track { background: transparent; }&lt;br /&gt;
    #messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }&lt;br /&gt;
&lt;br /&gt;
    /* System messages */&lt;br /&gt;
    .msg-system {&lt;br /&gt;
      text-align: center;&lt;br /&gt;
      font-size: 0.75rem;&lt;br /&gt;
      color: var(--muted);&lt;br /&gt;
      font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
      padding: 2px 0;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    /* Chat bubbles */&lt;br /&gt;
    .msg-bubble {&lt;br /&gt;
      display: flex;&lt;br /&gt;
      flex-direction: column;&lt;br /&gt;
      gap: 3px;&lt;br /&gt;
      max-width: 85%;&lt;br /&gt;
    }&lt;br /&gt;
    .msg-bubble.self { align-self: flex-end; align-items: flex-end; }&lt;br /&gt;
    .msg-bubble.other { align-self: flex-start; align-items: flex-start; }&lt;br /&gt;
&lt;br /&gt;
    .msg-meta {&lt;br /&gt;
      font-size: 0.7rem;&lt;br /&gt;
      color: var(--subtext);&lt;br /&gt;
      font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
      padding: 0 4px;&lt;br /&gt;
    }&lt;br /&gt;
    .msg-bubble.self .msg-meta { color: var(--accent); }&lt;br /&gt;
&lt;br /&gt;
    .msg-text {&lt;br /&gt;
      padding: 9px 13px;&lt;br /&gt;
      border-radius: 12px;&lt;br /&gt;
      font-size: 0.9rem;&lt;br /&gt;
      line-height: 1.5;&lt;br /&gt;
      word-break: break-word;&lt;br /&gt;
    }&lt;br /&gt;
    .msg-bubble.self .msg-text {&lt;br /&gt;
      background: var(--self-msg);&lt;br /&gt;
      border: 1px solid rgba(74,222,128,0.2);&lt;br /&gt;
      border-bottom-right-radius: 3px;&lt;br /&gt;
    }&lt;br /&gt;
    .msg-bubble.other .msg-text {&lt;br /&gt;
      background: var(--other-msg);&lt;br /&gt;
      border: 1px solid var(--border);&lt;br /&gt;
      border-bottom-left-radius: 3px;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    /* Links inside messages */&lt;br /&gt;
    .msg-text a {&lt;br /&gt;
      color: var(--accent2);&lt;br /&gt;
      text-decoration: underline;&lt;br /&gt;
      text-underline-offset: 2px;&lt;br /&gt;
      word-break: break-all;&lt;br /&gt;
    }&lt;br /&gt;
    .msg-text a:hover { opacity: 0.8; }&lt;br /&gt;
&lt;br /&gt;
    /* ── Input bar ── */&lt;br /&gt;
    #input-bar {&lt;br /&gt;
      display: flex;&lt;br /&gt;
      gap: 8px;&lt;br /&gt;
      padding: 12px 16px;&lt;br /&gt;
      border-top: 1px solid var(--border);&lt;br /&gt;
      background: var(--surface);&lt;br /&gt;
      flex-shrink: 0;&lt;br /&gt;
    }&lt;br /&gt;
    #msg-input {&lt;br /&gt;
      flex: 1;&lt;br /&gt;
      padding: 10px 14px;&lt;br /&gt;
      background: var(--bg);&lt;br /&gt;
      border: 1px solid var(--border);&lt;br /&gt;
      border-radius: 8px;&lt;br /&gt;
      color: var(--text);&lt;br /&gt;
      font-family: &#039;IBM Plex Sans&#039;, sans-serif;&lt;br /&gt;
      font-size: 0.9rem;&lt;br /&gt;
      outline: none;&lt;br /&gt;
      resize: none;&lt;br /&gt;
      height: 42px;&lt;br /&gt;
      max-height: 120px;&lt;br /&gt;
      overflow-y: auto;&lt;br /&gt;
      transition: border-color 0.2s;&lt;br /&gt;
    }&lt;br /&gt;
    #msg-input:focus { border-color: var(--accent); }&lt;br /&gt;
    #send-btn {&lt;br /&gt;
      padding: 0 16px;&lt;br /&gt;
      background: var(--accent);&lt;br /&gt;
      color: #0d0f14;&lt;br /&gt;
      border: none;&lt;br /&gt;
      border-radius: 8px;&lt;br /&gt;
      cursor: pointer;&lt;br /&gt;
      font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
      font-weight: 600;&lt;br /&gt;
      font-size: 0.85rem;&lt;br /&gt;
      flex-shrink: 0;&lt;br /&gt;
      transition: opacity 0.2s;&lt;br /&gt;
    }&lt;br /&gt;
    #send-btn:hover { opacity: 0.85; }&lt;br /&gt;
    #char-count {&lt;br /&gt;
      font-size: 0.7rem;&lt;br /&gt;
      color: var(--muted);&lt;br /&gt;
      font-family: &#039;IBM Plex Mono&#039;, monospace;&lt;br /&gt;
      align-self: flex-end;&lt;br /&gt;
      padding-bottom: 4px;&lt;br /&gt;
      flex-shrink: 0;&lt;br /&gt;
      width: 36px;&lt;br /&gt;
      text-align: right;&lt;br /&gt;
    }&lt;br /&gt;
    #char-count.warn { color: var(--danger); }&lt;br /&gt;
  &amp;lt;/style&amp;gt;&lt;br /&gt;
&amp;lt;/head&amp;gt;&lt;br /&gt;
&amp;lt;body&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;header&amp;gt;&lt;br /&gt;
  &amp;lt;h1&amp;gt;// webrtc&amp;lt;/h1&amp;gt;&lt;br /&gt;
  &amp;lt;span id=&amp;quot;room-badge&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;&lt;br /&gt;
  &amp;lt;div id=&amp;quot;status-dot&amp;quot; title=&amp;quot;Connecting...&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/header&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;!-- Join screen --&amp;gt;&lt;br /&gt;
&amp;lt;div id=&amp;quot;join-screen&amp;quot;&amp;gt;&lt;br /&gt;
  &amp;lt;h2&amp;gt;join a room&amp;lt;/h2&amp;gt;&lt;br /&gt;
  &amp;lt;p&amp;gt;Enter your name and a room name. Anyone with the same room name will connect with you.&amp;lt;/p&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;field-group&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;label&amp;gt;YOUR NAME&amp;lt;/label&amp;gt;&lt;br /&gt;
    &amp;lt;input id=&amp;quot;name-input&amp;quot; type=&amp;quot;text&amp;quot; placeholder=&amp;quot;e.g. Alice&amp;quot; maxlength=&amp;quot;24&amp;quot; /&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;div class=&amp;quot;field-group&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;label&amp;gt;ROOM NAME&amp;lt;/label&amp;gt;&lt;br /&gt;
    &amp;lt;input id=&amp;quot;room-input&amp;quot; type=&amp;quot;text&amp;quot; placeholder=&amp;quot;e.g. room1&amp;quot; value=&amp;quot;room1&amp;quot; maxlength=&amp;quot;32&amp;quot; /&amp;gt;&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
  &amp;lt;button class=&amp;quot;primary&amp;quot; onclick=&amp;quot;joinRoom()&amp;quot;&amp;gt;Join Room →&amp;lt;/button&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;!-- App (shown after joining) --&amp;gt;&lt;br /&gt;
&amp;lt;main&amp;gt;&lt;br /&gt;
  &amp;lt;div id=&amp;quot;app&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;!-- Left: videos --&amp;gt;&lt;br /&gt;
    &amp;lt;div id=&amp;quot;video-panel&amp;quot;&amp;gt;&lt;br /&gt;
      &amp;lt;div class=&amp;quot;video-wrapper&amp;quot; id=&amp;quot;local-wrapper&amp;quot;&amp;gt;&lt;br /&gt;
        &amp;lt;video id=&amp;quot;local-video&amp;quot; autoplay muted playsinline&amp;gt;&amp;lt;/video&amp;gt;&lt;br /&gt;
        &amp;lt;div class=&amp;quot;video-label&amp;quot; id=&amp;quot;local-label&amp;quot;&amp;gt;you&amp;lt;/div&amp;gt;&lt;br /&gt;
      &amp;lt;/div&amp;gt;&lt;br /&gt;
    &amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
    &amp;lt;!-- Right: chat --&amp;gt;&lt;br /&gt;
    &amp;lt;div id=&amp;quot;chat-panel&amp;quot;&amp;gt;&lt;br /&gt;
      &amp;lt;div id=&amp;quot;chat-header&amp;quot;&amp;gt;MESSAGES — &amp;lt;span id=&amp;quot;peer-count&amp;quot;&amp;gt;0 others in room&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;br /&gt;
      &amp;lt;div id=&amp;quot;messages&amp;quot;&amp;gt;&lt;br /&gt;
        &amp;lt;div class=&amp;quot;msg-system&amp;quot;&amp;gt;Chat is end-to-end via your server. Video is peer-to-peer.&amp;lt;/div&amp;gt;&lt;br /&gt;
      &amp;lt;/div&amp;gt;&lt;br /&gt;
      &amp;lt;div id=&amp;quot;input-bar&amp;quot;&amp;gt;&lt;br /&gt;
        &amp;lt;textarea id=&amp;quot;msg-input&amp;quot; placeholder=&amp;quot;Type a message… (Enter to send, Shift+Enter for newline)&amp;quot; rows=&amp;quot;1&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
        &amp;lt;span id=&amp;quot;char-count&amp;quot;&amp;gt;500&amp;lt;/span&amp;gt;&lt;br /&gt;
        &amp;lt;button id=&amp;quot;send-btn&amp;quot; onclick=&amp;quot;sendMessage()&amp;quot;&amp;gt;Send&amp;lt;/button&amp;gt;&lt;br /&gt;
      &amp;lt;/div&amp;gt;&lt;br /&gt;
    &amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/main&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;script src=&amp;quot;/socket.io/socket.io.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;&lt;br /&gt;
&amp;lt;script&amp;gt;&lt;br /&gt;
  const socket = io();&lt;br /&gt;
  let localStream;&lt;br /&gt;
  let myName = &#039;Anonymous&#039;;&lt;br /&gt;
  let currentRoom = &#039;&#039;;&lt;br /&gt;
  let peerCount = 0;&lt;br /&gt;
  const peers = {};&lt;br /&gt;
  const MAX_MSG = 500;&lt;br /&gt;
&lt;br /&gt;
  const iceConfig = {&lt;br /&gt;
    iceServers: [{ urls: &#039;stun:stun.l.google.com:19302&#039; }]&lt;br /&gt;
  };&lt;br /&gt;
&lt;br /&gt;
  // ── Helpers ──────────────────────────────────────────────&lt;br /&gt;
&lt;br /&gt;
  // Detect URLs in text and wrap them in &amp;lt;a&amp;gt; tags&lt;br /&gt;
  function linkify(text) {&lt;br /&gt;
    const escaped = text&lt;br /&gt;
      .replace(/&amp;amp;/g, &#039;&amp;amp;amp;&#039;)&lt;br /&gt;
      .replace(/&amp;lt;/g, &#039;&amp;amp;lt;&#039;)&lt;br /&gt;
      .replace(/&amp;gt;/g, &#039;&amp;amp;gt;&#039;);&lt;br /&gt;
    const urlRegex = /(https?:\/\/[^\s&amp;lt;&amp;gt;&amp;quot;]+)/g;&lt;br /&gt;
    return escaped.replace(urlRegex, &#039;&amp;lt;a href=&amp;quot;$1&amp;quot; target=&amp;quot;_blank&amp;quot; rel=&amp;quot;noopener noreferrer&amp;quot;&amp;gt;$1&amp;lt;/a&amp;gt;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function addMessage({ name, message, isSelf, isSystem }) {&lt;br /&gt;
    const container = document.getElementById(&#039;messages&#039;);&lt;br /&gt;
&lt;br /&gt;
    if (isSystem) {&lt;br /&gt;
      const el = document.createElement(&#039;div&#039;);&lt;br /&gt;
      el.className = &#039;msg-system&#039;;&lt;br /&gt;
      el.textContent = message;&lt;br /&gt;
      container.appendChild(el);&lt;br /&gt;
    } else {&lt;br /&gt;
      const bubble = document.createElement(&#039;div&#039;);&lt;br /&gt;
      bubble.className = &#039;msg-bubble &#039; + (isSelf ? &#039;self&#039; : &#039;other&#039;);&lt;br /&gt;
&lt;br /&gt;
      const now = new Date().toLocaleTimeString([], { hour: &#039;2-digit&#039;, minute: &#039;2-digit&#039; });&lt;br /&gt;
      const meta = document.createElement(&#039;div&#039;);&lt;br /&gt;
      meta.className = &#039;msg-meta&#039;;&lt;br /&gt;
      meta.textContent = (isSelf ? &#039;you&#039; : name) + &#039; · &#039; + now;&lt;br /&gt;
&lt;br /&gt;
      const body = document.createElement(&#039;div&#039;);&lt;br /&gt;
      body.className = &#039;msg-text&#039;;&lt;br /&gt;
      // Linkify and preserve newlines&lt;br /&gt;
      body.innerHTML = linkify(message).replace(/\n/g, &#039;&amp;lt;br&amp;gt;&#039;);&lt;br /&gt;
&lt;br /&gt;
      bubble.appendChild(meta);&lt;br /&gt;
      bubble.appendChild(body);&lt;br /&gt;
      container.appendChild(bubble);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    // Auto-scroll to bottom&lt;br /&gt;
    container.scrollTop = container.scrollHeight;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function updatePeerCount() {&lt;br /&gt;
    document.getElementById(&#039;peer-count&#039;).textContent =&lt;br /&gt;
      peerCount === 0 ? &#039;no others in room yet&#039;&lt;br /&gt;
      : peerCount === 1 ? &#039;1 other in room&#039;&lt;br /&gt;
      : `${peerCount} others in room`;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  // ── Join ─────────────────────────────────────────────────&lt;br /&gt;
&lt;br /&gt;
  async function joinRoom() {&lt;br /&gt;
    const nameVal = document.getElementById(&#039;name-input&#039;).value.trim();&lt;br /&gt;
    const roomVal = document.getElementById(&#039;room-input&#039;).value.trim();&lt;br /&gt;
&lt;br /&gt;
    if (!roomVal) { alert(&#039;Please enter a room name&#039;); return; }&lt;br /&gt;
    myName = nameVal || &#039;Anonymous&#039;;&lt;br /&gt;
    currentRoom = roomVal;&lt;br /&gt;
&lt;br /&gt;
    try {&lt;br /&gt;
      localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });&lt;br /&gt;
      document.getElementById(&#039;local-video&#039;).srcObject = localStream;&lt;br /&gt;
      document.getElementById(&#039;local-label&#039;).textContent = myName + &#039; (you)&#039;;&lt;br /&gt;
    } catch (err) {&lt;br /&gt;
      alert(&#039;Could not access camera/microphone: &#039; + err.message);&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&#039;join-screen&#039;).style.display = &#039;none&#039;;&lt;br /&gt;
    document.getElementById(&#039;app&#039;).classList.add(&#039;visible&#039;);&lt;br /&gt;
    document.getElementById(&#039;room-badge&#039;).textContent = &#039;# &#039; + currentRoom;&lt;br /&gt;
    document.getElementById(&#039;room-badge&#039;).style.display = &#039;&#039;;&lt;br /&gt;
    document.getElementById(&#039;status-dot&#039;).classList.add(&#039;live&#039;);&lt;br /&gt;
&lt;br /&gt;
    socket.emit(&#039;join&#039;, currentRoom);&lt;br /&gt;
    addMessage({ isSystem: true, message: `You joined #${currentRoom} as &amp;quot;${myName}&amp;quot;` });&lt;br /&gt;
    updatePeerCount();&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  // ── Chat ─────────────────────────────────────────────────&lt;br /&gt;
&lt;br /&gt;
  function sendMessage() {&lt;br /&gt;
    const input = document.getElementById(&#039;msg-input&#039;);&lt;br /&gt;
    const text = input.value.trim();&lt;br /&gt;
    if (!text || !currentRoom) return;&lt;br /&gt;
    if (text.length &amp;gt; MAX_MSG) { alert(`Max ${MAX_MSG} characters`); return; }&lt;br /&gt;
&lt;br /&gt;
    socket.emit(&#039;chat-message&#039;, { room: currentRoom, message: text, name: myName });&lt;br /&gt;
    addMessage({ name: myName, message: text, isSelf: true });&lt;br /&gt;
&lt;br /&gt;
    input.value = &#039;&#039;;&lt;br /&gt;
    input.style.height = &#039;42px&#039;;&lt;br /&gt;
    document.getElementById(&#039;char-count&#039;).textContent = MAX_MSG;&lt;br /&gt;
    document.getElementById(&#039;char-count&#039;).classList.remove(&#039;warn&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  socket.on(&#039;chat-message&#039;, ({ name, message }) =&amp;gt; {&lt;br /&gt;
    addMessage({ name, message, isSelf: false });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Enter to send, Shift+Enter for newline&lt;br /&gt;
  document.getElementById(&#039;msg-input&#039;).addEventListener(&#039;keydown&#039;, (e) =&amp;gt; {&lt;br /&gt;
    if (e.key === &#039;Enter&#039; &amp;amp;&amp;amp; !e.shiftKey) {&lt;br /&gt;
      e.preventDefault();&lt;br /&gt;
      sendMessage();&lt;br /&gt;
    }&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Char counter + auto-resize textarea&lt;br /&gt;
  document.getElementById(&#039;msg-input&#039;).addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
    const remaining = MAX_MSG - this.value.length;&lt;br /&gt;
    const counter = document.getElementById(&#039;char-count&#039;);&lt;br /&gt;
    counter.textContent = remaining;&lt;br /&gt;
    counter.classList.toggle(&#039;warn&#039;, remaining &amp;lt; 50);&lt;br /&gt;
    this.style.height = &#039;42px&#039;;&lt;br /&gt;
    this.style.height = Math.min(this.scrollHeight, 120) + &#039;px&#039;;&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // ── WebRTC ───────────────────────────────────────────────&lt;br /&gt;
&lt;br /&gt;
  socket.on(&#039;room-users&#039;, async (users) =&amp;gt; {&lt;br /&gt;
    peerCount = users.length;&lt;br /&gt;
    updatePeerCount();&lt;br /&gt;
    for (const userId of users) {&lt;br /&gt;
      await createOffer(userId);&lt;br /&gt;
    }&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  socket.on(&#039;offer&#039;, async ({ from, offer }) =&amp;gt; {&lt;br /&gt;
    const pc = createPeerConnection(from);&lt;br /&gt;
    await pc.setRemoteDescription(new RTCSessionDescription(offer));&lt;br /&gt;
    const answer = await pc.createAnswer();&lt;br /&gt;
    await pc.setLocalDescription(answer);&lt;br /&gt;
    socket.emit(&#039;answer&#039;, { to: from, answer });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  socket.on(&#039;answer&#039;, async ({ from, answer }) =&amp;gt; {&lt;br /&gt;
    const pc = peers[from];&lt;br /&gt;
    if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  socket.on(&#039;ice-candidate&#039;, async ({ from, candidate }) =&amp;gt; {&lt;br /&gt;
    const pc = peers[from];&lt;br /&gt;
    if (pc &amp;amp;&amp;amp; candidate) {&lt;br /&gt;
      try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) {}&lt;br /&gt;
    }&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  socket.on(&#039;user-left&#039;, (userId) =&amp;gt; {&lt;br /&gt;
    if (peers[userId]) { peers[userId].close(); delete peers[userId]; }&lt;br /&gt;
    const el = document.getElementById(&#039;video-&#039; + userId);&lt;br /&gt;
    if (el) el.closest(&#039;.video-wrapper&#039;).remove();&lt;br /&gt;
    peerCount = Math.max(0, peerCount - 1);&lt;br /&gt;
    updatePeerCount();&lt;br /&gt;
    addMessage({ isSystem: true, message: &#039;A user left the room.&#039; });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  function createPeerConnection(peerId) {&lt;br /&gt;
    const pc = new RTCPeerConnection(iceConfig);&lt;br /&gt;
    peers[peerId] = pc;&lt;br /&gt;
&lt;br /&gt;
    localStream.getTracks().forEach((track) =&amp;gt; pc.addTrack(track, localStream));&lt;br /&gt;
&lt;br /&gt;
    pc.onicecandidate = ({ candidate }) =&amp;gt; {&lt;br /&gt;
      if (candidate) socket.emit(&#039;ice-candidate&#039;, { to: peerId, candidate });&lt;br /&gt;
    };&lt;br /&gt;
&lt;br /&gt;
    pc.ontrack = ({ streams }) =&amp;gt; {&lt;br /&gt;
      let wrapper = document.getElementById(&#039;wrapper-&#039; + peerId);&lt;br /&gt;
      if (!wrapper) {&lt;br /&gt;
        wrapper = document.createElement(&#039;div&#039;);&lt;br /&gt;
        wrapper.className = &#039;video-wrapper&#039;;&lt;br /&gt;
        wrapper.id = &#039;wrapper-&#039; + peerId;&lt;br /&gt;
&lt;br /&gt;
        const vid = document.createElement(&#039;video&#039;);&lt;br /&gt;
        vid.id = &#039;video-&#039; + peerId;&lt;br /&gt;
        vid.autoplay = true;&lt;br /&gt;
        vid.playsInline = true;&lt;br /&gt;
&lt;br /&gt;
        const label = document.createElement(&#039;div&#039;);&lt;br /&gt;
        label.className = &#039;video-label&#039;;&lt;br /&gt;
        label.textContent = &#039;peer&#039;;&lt;br /&gt;
&lt;br /&gt;
        wrapper.appendChild(vid);&lt;br /&gt;
        wrapper.appendChild(label);&lt;br /&gt;
        document.getElementById(&#039;video-panel&#039;).appendChild(wrapper);&lt;br /&gt;
&lt;br /&gt;
        peerCount = Object.keys(peers).length;&lt;br /&gt;
        updatePeerCount();&lt;br /&gt;
      }&lt;br /&gt;
      document.getElementById(&#039;video-&#039; + peerId).srcObject = streams[0];&lt;br /&gt;
    };&lt;br /&gt;
&lt;br /&gt;
    return pc;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  async function createOffer(peerId) {&lt;br /&gt;
    const pc = createPeerConnection(peerId);&lt;br /&gt;
    const offer = await pc.createOffer();&lt;br /&gt;
    await pc.setLocalDescription(offer);&lt;br /&gt;
    socket.emit(&#039;offer&#039;, { to: peerId, offer });&lt;br /&gt;
  }&lt;br /&gt;
&amp;lt;/script&amp;gt;&lt;br /&gt;
&amp;lt;/body&amp;gt;&lt;br /&gt;
&amp;lt;/html&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
* Edit &amp;lt;code&amp;gt;server.js&amp;lt;/code&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Appended &amp;lt;b&amp;gt;server.js&amp;lt;/b&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
const express = require(&#039;express&#039;);&lt;br /&gt;
const https = require(&#039;https&#039;);&lt;br /&gt;
const { Server } = require(&#039;socket.io&#039;);&lt;br /&gt;
const path = require(&#039;path&#039;);&lt;br /&gt;
&lt;br /&gt;
const app = express();&lt;br /&gt;
const fs = require(&#039;fs&#039;);&lt;br /&gt;
&lt;br /&gt;
const server = https.createServer({&lt;br /&gt;
  key: fs.readFileSync(&#039;/opt/webrtc/key.pem&#039;),&lt;br /&gt;
  cert: fs.readFileSync(&#039;/opt/webrtc/cert.pem&#039;),&lt;br /&gt;
}, app);&lt;br /&gt;
const io = new Server(server);&lt;br /&gt;
&lt;br /&gt;
// Serve the frontend HTML file&lt;br /&gt;
app.use(express.static(path.join(__dirname, &#039;public&#039;)));&lt;br /&gt;
&lt;br /&gt;
// Keep track of who is in each room&lt;br /&gt;
const rooms = {};&lt;br /&gt;
&lt;br /&gt;
io.on(&#039;connection&#039;, (socket) =&amp;gt; {&lt;br /&gt;
  console.log(&#039;A user connected:&#039;, socket.id);&lt;br /&gt;
&lt;br /&gt;
  // User wants to join a room&lt;br /&gt;
  socket.on(&#039;join&#039;, (room) =&amp;gt; {&lt;br /&gt;
    socket.join(room);&lt;br /&gt;
&lt;br /&gt;
    if (!rooms[room]) {&lt;br /&gt;
      rooms[room] = [];&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    // Tell the new user who is already in the room&lt;br /&gt;
    socket.emit(&#039;room-users&#039;, rooms[room]);&lt;br /&gt;
&lt;br /&gt;
    // Add this user to the room list&lt;br /&gt;
    rooms[room].push(socket.id);&lt;br /&gt;
&lt;br /&gt;
    console.log(`${socket.id} joined room: ${room}`);&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Relay a WebRTC offer to a specific user&lt;br /&gt;
  socket.on(&#039;offer&#039;, ({ to, offer }) =&amp;gt; {&lt;br /&gt;
    io.to(to).emit(&#039;offer&#039;, { from: socket.id, offer });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Relay a WebRTC answer to a specific user&lt;br /&gt;
  socket.on(&#039;answer&#039;, ({ to, answer }) =&amp;gt; {&lt;br /&gt;
    io.to(to).emit(&#039;answer&#039;, { from: socket.id, answer });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // Relay ICE candidates between peers&lt;br /&gt;
  socket.on(&#039;ice-candidate&#039;, ({ to, candidate }) =&amp;gt; {&lt;br /&gt;
    io.to(to).emit(&#039;ice-candidate&#039;, { from: socket.id, candidate });&lt;br /&gt;
  });&lt;br /&gt;
&lt;br /&gt;
  // ---- CHAT ----&lt;br /&gt;
  // Relay a chat message to everyone else in the room&lt;br /&gt;
  socket.on(&#039;chat-message&#039;, ({ room, message, name }) =&amp;gt; {&lt;br /&gt;
    // Broadcast to everyone in the room EXCEPT the sender&lt;br /&gt;
    socket.to(room).emit(&#039;chat-message&#039;, {&lt;br /&gt;
      from: socket.id,&lt;br /&gt;
      name: name || &#039;Anonymous&#039;,&lt;br /&gt;
      message,&lt;br /&gt;
      time: new Date().toLocaleTimeString([], { hour: &#039;2-digit&#039;, minute: &#039;2-digit&#039; })&lt;br /&gt;
    });&lt;br /&gt;
  });&lt;br /&gt;
  // ---- END CHAT ----&lt;br /&gt;
&lt;br /&gt;
  // Clean up when a user disconnects&lt;br /&gt;
  socket.on(&#039;disconnect&#039;, () =&amp;gt; {&lt;br /&gt;
    for (const room in rooms) {&lt;br /&gt;
      rooms[room] = rooms[room].filter((id) =&amp;gt; id !== socket.id);&lt;br /&gt;
      // Tell others in the room this user left&lt;br /&gt;
      socket.to(room).emit(&#039;user-left&#039;, socket.id);&lt;br /&gt;
    }&lt;br /&gt;
    console.log(&#039;User disconnected:&#039;, socket.id);&lt;br /&gt;
  });&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
const PORT = 3000;&lt;br /&gt;
server.listen(PORT, &#039;0.0.0.0&#039;, () =&amp;gt; {&lt;br /&gt;
  console.log(`Signalling server running on port ${PORT}`);&lt;br /&gt;
});&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Restart webrtc: &amp;lt;code&amp;gt;systemctl restart webrtc&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Test - Should be working&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
=== &amp;quot;Cannot connect to the page&amp;quot; ===&lt;br /&gt;
* Make sure the container is running: &amp;lt;code&amp;gt;sudo lxc-ls --fancy&amp;lt;/code&amp;gt;&lt;br /&gt;
* Make sure the service is running: attach to the container and run &amp;lt;code&amp;gt;systemctl status webrtc&amp;lt;/code&amp;gt;&lt;br /&gt;
* Double-check the IP address — it can change after a container restart. Assign a static IP via LXC config if needed.&lt;br /&gt;
&lt;br /&gt;
=== &amp;quot;Camera/Microphone access denied&amp;quot; ===&lt;br /&gt;
* On non-localhost addresses, browsers require HTTPS. See Part 7.&lt;br /&gt;
* Make sure you clicked &#039;&#039;&#039;Allow&#039;&#039;&#039; (not Block) when the browser asked for permissions.&lt;br /&gt;
* Try in a different browser, or clear site permissions in browser settings.&lt;br /&gt;
&lt;br /&gt;
=== Two tabs connect but no video appears ===&lt;br /&gt;
* This usually means ICE negotiation is failing. For local testing this should not happen.&lt;br /&gt;
* If testing across networks (not just local LAN), you will need a &#039;&#039;&#039;TURN server&#039;&#039;&#039; (e.g. coturn). This is beyond the scope of this guide.&lt;br /&gt;
&lt;br /&gt;
=== Port 3000 not reachable ===&lt;br /&gt;
* Check no firewall is blocking it on the host: &amp;lt;code&amp;gt;sudo ufw status&amp;lt;/code&amp;gt;&lt;br /&gt;
* LXC containers on the default &amp;lt;code&amp;gt;lxcbr0&amp;lt;/code&amp;gt; bridge are reachable from the host by default. No port forwarding should be needed for local access.&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=User:ChandraArgueta&amp;diff=713</id>
		<title>User:ChandraArgueta</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=User:ChandraArgueta&amp;diff=713"/>
		<updated>2026-01-06T20:23:02Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Blanked the page&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=User:StephaniaEberhar&amp;diff=711</id>
		<title>User:StephaniaEberhar</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=User:StephaniaEberhar&amp;diff=711"/>
		<updated>2026-01-06T20:22:16Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Blanked the page&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=User:ReinaldoTonga76&amp;diff=710</id>
		<title>User:ReinaldoTonga76</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=User:ReinaldoTonga76&amp;diff=710"/>
		<updated>2026-01-06T20:21:40Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Blanked the page&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Why_New_18_Video_Platforms_Are_Improving_The_User_Experience&amp;diff=709</id>
		<title>Why New 18 Video Platforms Are Improving The User Experience</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Why_New_18_Video_Platforms_Are_Improving_The_User_Experience&amp;diff=709"/>
		<updated>2026-01-06T20:21:05Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Blanked the page&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_2&amp;diff=678</id>
		<title>CompleteNoobs Docker Image 2</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_2&amp;diff=678"/>
		<updated>2025-09-02T14:46:21Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* 5.2: Test Extensions */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;br /&gt;
==todo==&lt;br /&gt;
* Use Dir for import export of xml.&lt;br /&gt;
* Min scripts - just setup mediawiki with extensions.&lt;br /&gt;
&lt;br /&gt;
= Complete Noobs Docker Wiki Tutorial v0.2 =&lt;br /&gt;
* Version 0.2 - Simplified with manual import/export&lt;br /&gt;
* [[Docker_Install_Guide| Docker install guide]]&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
This tutorial creates a MediaWiki Docker container with:&lt;br /&gt;
* MediaWiki 1.44&lt;br /&gt;
* PageNotice extension (for license notices)&lt;br /&gt;
* YouTube extension (for video embedding)&lt;br /&gt;
* Shared directory for XML import/export&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
* Ubuntu 24.04&lt;br /&gt;
* Docker installed and running&lt;br /&gt;
* Your user in docker group: &amp;lt;code&amp;gt;sudo usermod -aG docker $USER&amp;lt;/code&amp;gt; (then logout/login)&lt;br /&gt;
&lt;br /&gt;
== Step 1: Create Directory Structure ==&lt;br /&gt;
&lt;br /&gt;
=== 1.1: Create Project Directory ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir ~/completenoobs-docker-v2&lt;br /&gt;
cd ~/completenoobs-docker-v2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 1.2: Create Shared Directory ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This directory will be used for importing and exporting XML files.&lt;br /&gt;
&lt;br /&gt;
== Step 2: Create All Files ==&lt;br /&gt;
&lt;br /&gt;
=== 2.1: Dockerfile ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano Dockerfile&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;dockerfile&amp;quot;&amp;gt;&lt;br /&gt;
FROM mediawiki:1.44&lt;br /&gt;
&lt;br /&gt;
# Install dependencies&lt;br /&gt;
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \&lt;br /&gt;
    mariadb-server \&lt;br /&gt;
    python3-pygments \&lt;br /&gt;
    curl \&lt;br /&gt;
    wget \&lt;br /&gt;
    unzip \&lt;br /&gt;
    nano \&lt;br /&gt;
    vim \&lt;br /&gt;
    git \&lt;br /&gt;
    &amp;amp;&amp;amp; apt-get clean&lt;br /&gt;
&lt;br /&gt;
# Copy setup script&lt;br /&gt;
COPY setup_wiki.sh /usr/src/setup_wiki.sh&lt;br /&gt;
COPY entrypoint.sh /entrypoint.sh&lt;br /&gt;
&lt;br /&gt;
# Make executable&lt;br /&gt;
RUN chmod +x /usr/src/setup_wiki.sh /entrypoint.sh&lt;br /&gt;
&lt;br /&gt;
# Setup wiki&lt;br /&gt;
RUN /usr/src/setup_wiki.sh&lt;br /&gt;
&lt;br /&gt;
EXPOSE 80&lt;br /&gt;
VOLUME /var/lib/mysql&lt;br /&gt;
VOLUME /var/www/html/images&lt;br /&gt;
VOLUME /export&lt;br /&gt;
&lt;br /&gt;
ENTRYPOINT [&amp;quot;/entrypoint.sh&amp;quot;]&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.2: Main Setup Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano setup_wiki.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Setting up CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Initialize MariaDB&lt;br /&gt;
if [ ! -d &amp;quot;/var/lib/mysql/mysql&amp;quot; ]; then&lt;br /&gt;
    mysql_install_db --user=mysql --datadir=/var/lib/mysql&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 2&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
# Setup database&lt;br /&gt;
mysql -e &amp;quot;CREATE DATABASE IF NOT EXISTS completenoobs_wiki CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;127.0.0.1&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;127.0.0.1&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;localhost&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;localhost&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;FLUSH PRIVILEGES;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Install MediaWiki&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
php maintenance/install.php \&lt;br /&gt;
    --dbtype=mysql \&lt;br /&gt;
    --dbserver=127.0.0.1 \&lt;br /&gt;
    --dbname=completenoobs_wiki \&lt;br /&gt;
    --dbuser=wikiuser \&lt;br /&gt;
    --dbpass=wikipass \&lt;br /&gt;
    --server=&amp;quot;http://localhost:8080&amp;quot; \&lt;br /&gt;
    --scriptpath=&amp;quot;&amp;quot; \&lt;br /&gt;
    --lang=en \&lt;br /&gt;
    --pass=AdminPass123! \&lt;br /&gt;
    &amp;quot;CompleteNoobs Wiki&amp;quot; \&lt;br /&gt;
    &amp;quot;admin&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Download and install extensions&lt;br /&gt;
cd extensions/&lt;br /&gt;
echo &amp;quot;Installing PageNotice extension...&amp;quot;&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/PageNotice --branch REL1_44 || echo &amp;quot;PageNotice download failed, continuing...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Installing YouTube extension...&amp;quot;&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/YouTube --branch REL1_44 || echo &amp;quot;YouTube download failed, continuing...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
&lt;br /&gt;
# Configure LocalSettings.php&lt;br /&gt;
cat &amp;gt;&amp;gt; LocalSettings.php &amp;lt;&amp;lt; &#039;EOF&#039;&lt;br /&gt;
&lt;br /&gt;
# Basic settings&lt;br /&gt;
$wgEnableUploads = true;&lt;br /&gt;
$wgUseImageMagick = true;&lt;br /&gt;
$wgImageMagickConvertCommand = &amp;quot;/usr/bin/convert&amp;quot;;&lt;br /&gt;
$wgDefaultSkin = &amp;quot;vector-2022&amp;quot;;&lt;br /&gt;
$wgAllowExternalImages = true;&lt;br /&gt;
&lt;br /&gt;
# Debug settings (remove in production)&lt;br /&gt;
$wgShowExceptionDetails = true;&lt;br /&gt;
$wgDebugLogFile = &amp;quot;/tmp/mediawiki-debug.log&amp;quot;;&lt;br /&gt;
&lt;br /&gt;
# PageNotice extension&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/PageNotice/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;PageNotice&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# YouTube extension&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/YouTube/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;YouTube&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# SyntaxHighlight (usually bundled)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/SyntaxHighlight_GeSHi/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;SyntaxHighlight_GeSHi&#039; );&lt;br /&gt;
    $wgPygmentizePath = &#039;/usr/bin/pygmentize&#039;;&lt;br /&gt;
}&lt;br /&gt;
EOF&lt;br /&gt;
&lt;br /&gt;
# Create helper scripts&lt;br /&gt;
cat &amp;gt; /var/www/html/export_wiki.sh &amp;lt;&amp;lt; &#039;EXPORT_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
echo &amp;quot;=== Wiki Export Tool ===&amp;quot;&lt;br /&gt;
DATE=$(date +%Y%m%d)&lt;br /&gt;
OUTPUT_FILE=&amp;quot;/export/${DATE}_wiki_export.xml&amp;quot;&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Exporting wiki to: $OUTPUT_FILE&amp;quot;&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:$OUTPUT_FILE&lt;br /&gt;
&lt;br /&gt;
if [ -f &amp;quot;$OUTPUT_FILE&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Export successful!&amp;quot;&lt;br /&gt;
    echo &amp;quot;File saved to: $OUTPUT_FILE&amp;quot;&lt;br /&gt;
    echo &amp;quot;On host system: ~/wiki-container/${DATE}_wiki_export.xml&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;Export failed!&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
EXPORT_EOF&lt;br /&gt;
chmod +x /var/www/html/export_wiki.sh&lt;br /&gt;
&lt;br /&gt;
cat &amp;gt; /var/www/html/import_wiki.sh &amp;lt;&amp;lt; &#039;IMPORT_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
echo &amp;quot;=== Wiki Import Tool ===&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Available XML files in /export:&amp;quot;&lt;br /&gt;
ls -la /export/*.xml 2&amp;gt;/dev/null || echo &amp;quot;No XML files found&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
if [ -z &amp;quot;$1&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Usage: /var/www/html/import_wiki.sh &amp;lt;filename&amp;gt;&amp;quot;&lt;br /&gt;
    echo &amp;quot;Example: /var/www/html/import_wiki.sh /export/wiki.xml&amp;quot;&lt;br /&gt;
    exit 1&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
if [ ! -f &amp;quot;$1&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Error: File $1 not found!&amp;quot;&lt;br /&gt;
    exit 1&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Importing from: $1&amp;quot;&lt;br /&gt;
echo &amp;quot;This may take several minutes...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php &amp;quot;$1&amp;quot;&lt;br /&gt;
&lt;br /&gt;
if [ $? -eq 0 ]; then&lt;br /&gt;
    echo &amp;quot;Import completed!&amp;quot;&lt;br /&gt;
    echo &amp;quot;Rebuilding indexes...&amp;quot;&lt;br /&gt;
    php /var/www/html/maintenance/run.php rebuildrecentchanges.php&lt;br /&gt;
    php /var/www/html/maintenance/run.php initSiteStats.php&lt;br /&gt;
    echo &amp;quot;Done!&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;Import failed! Check /tmp/mediawiki-debug.log for details&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
IMPORT_EOF&lt;br /&gt;
chmod +x /var/www/html/import_wiki.sh&lt;br /&gt;
&lt;br /&gt;
# Create status check script&lt;br /&gt;
cat &amp;gt; /var/www/html/check_status.sh &amp;lt;&amp;lt; &#039;STATUS_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
echo &amp;quot;=== Wiki Status ===&amp;quot;&lt;br /&gt;
echo &amp;quot;Pages: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;Users: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM user WHERE user_id &amp;gt; 0;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Extensions ===&amp;quot;&lt;br /&gt;
if [ -d &amp;quot;extensions/PageNotice&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;PageNotice: Installed ✓&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;PageNotice: Not installed ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/YouTube&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;YouTube: Installed ✓&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;YouTube: Not installed ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/SyntaxHighlight_GeSHi&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;SyntaxHighlight: Installed ✓&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;SyntaxHighlight: Not installed ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Shared Directory ===&amp;quot;&lt;br /&gt;
if [ -d &amp;quot;/export&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;/export mounted ✓&amp;quot;&lt;br /&gt;
    echo &amp;quot;Files in /export:&amp;quot;&lt;br /&gt;
    ls -la /export/*.xml 2&amp;gt;/dev/null || echo &amp;quot;  No XML files&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;/export not mounted ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
STATUS_EOF&lt;br /&gt;
chmod +x /var/www/html/check_status.sh&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Setup completed!&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin: admin / AdminPass123!&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb stop&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.3: Entrypoint Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano entrypoint.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Starting CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 1&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=========================================&amp;quot;&lt;br /&gt;
echo &amp;quot;CompleteNoobs Wiki v0.2 Ready!&amp;quot;&lt;br /&gt;
echo &amp;quot;=========================================&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Access: http://localhost:8080&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Features:&amp;quot;&lt;br /&gt;
echo &amp;quot;- MediaWiki 1.44&amp;quot;&lt;br /&gt;
echo &amp;quot;- PageNotice extension&amp;quot;&lt;br /&gt;
echo &amp;quot;- YouTube extension&amp;quot;&lt;br /&gt;
echo &amp;quot;- SyntaxHighlight extension&amp;quot;&lt;br /&gt;
echo &amp;quot;- Import/Export via ~/wiki-container&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Available commands:&amp;quot;&lt;br /&gt;
echo &amp;quot;- Export wiki: docker exec completenoobs_wiki /var/www/html/export_wiki.sh&amp;quot;&lt;br /&gt;
echo &amp;quot;- Import XML: docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/filename.xml&amp;quot;&lt;br /&gt;
echo &amp;quot;- Check status: docker exec completenoobs_wiki /var/www/html/check_status.sh&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
apache2-foreground&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 3: Build and Run ==&lt;br /&gt;
&lt;br /&gt;
=== 3.1: Build the Image ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker build -t completenoobs/wiki:v0.2 .&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3.2: Run the Container with Shared Directory ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/wiki:v0.2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 4: Import/Export Operations ==&lt;br /&gt;
&lt;br /&gt;
=== 4.1: Import an XML File ===&lt;br /&gt;
* https://xml.completenoobs.com/xmlDumps/&lt;br /&gt;
==== Place XML file in shared directory ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Copy your XML file to the shared directory&lt;br /&gt;
cp ~/Downloads/completenoobs.xml ~/wiki-container/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Import the file ====&lt;br /&gt;
* All files in host &amp;lt;code&amp;gt;wiki-container&amp;lt;/code&amp;gt; directory will also be in the containers &amp;lt;code&amp;gt;/export/&amp;lt;/code&amp;gt; directory.&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Run the import command&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/completenoobs.xml&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{:Restore_the_completenoobs_Main_Page}}&lt;br /&gt;
&lt;br /&gt;
=== 4.2: Export Your Wiki ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Export wiki to dated XML file&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/export_wiki.sh&lt;br /&gt;
&lt;br /&gt;
# Check the exported file&lt;br /&gt;
ls -la ~/wiki-container/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.3: Manual Import/Export (Advanced) ===&lt;br /&gt;
&lt;br /&gt;
==== Access container shell ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Manual export with custom filename ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:/export/my_wiki_backup.xml&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Manual import with options ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Basic import&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php /export/wiki.xml&lt;br /&gt;
&lt;br /&gt;
# Import with image uploads&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php --uploads /export/wiki.xml&lt;br /&gt;
&lt;br /&gt;
# Then rebuild indexes&lt;br /&gt;
php /var/www/html/maintenance/run.php rebuildall.php&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 5: Testing and Verification ==&lt;br /&gt;
&lt;br /&gt;
=== 5.1: Check Wiki Status ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/check_status.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5.2: Test Extensions ===&lt;br /&gt;
1. Visit http://localhost:8080&lt;br /&gt;
2. Login with admin / AdminPass123!&lt;br /&gt;
3. Create or edit a page and test:&lt;br /&gt;
   * YouTube: Add/Test &amp;lt;nowiki&amp;gt;&amp;lt;youtube&amp;gt;gBML6zuUpK0&amp;lt;/youtube&amp;gt;&amp;lt;/nowiki&amp;gt;  &amp;lt;code&amp;gt;&amp;lt;youtube&amp;gt;gBML6zuUpK0&amp;lt;/youtube&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
   * SyntaxHighlight: Add &amp;lt;nowiki&amp;gt;&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;print(&amp;quot;Hello&amp;quot;)&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;/nowiki&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5.3: View Logs ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Container logs&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&lt;br /&gt;
# MediaWiki debug log&lt;br /&gt;
docker exec completenoobs_wiki tail -f /tmp/mediawiki-debug.log&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 6: Common Operations ==&lt;br /&gt;
&lt;br /&gt;
=== 6.1: Change Admin Password ===&lt;br /&gt;
&lt;br /&gt;
==== Via Web Interface ====&lt;br /&gt;
1. Login at http://localhost:8080&lt;br /&gt;
2. Click username → Preferences&lt;br /&gt;
3. Go to Password tab&lt;br /&gt;
4. Change password&lt;br /&gt;
&lt;br /&gt;
==== Via Terminal ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6.2: Backup Your Wiki ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Create dated backup&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/export_wiki.sh&lt;br /&gt;
&lt;br /&gt;
# Copy to safe location&lt;br /&gt;
cp ~/wiki-container/*_wiki_export.xml ~/backups/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6.3: Restore from Backup ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Place backup in shared directory&lt;br /&gt;
cp ~/backups/20250101_wiki_export.xml ~/wiki-container/&lt;br /&gt;
&lt;br /&gt;
# Import the backup&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/20250101_wiki_export.xml&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6.4: Complete Reset ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Stop and remove container&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
&lt;br /&gt;
# Remove volumes (WARNING: This deletes all data!)&lt;br /&gt;
docker volume rm completenoobs_mysql completenoobs_images&lt;br /&gt;
&lt;br /&gt;
# Rebuild and start fresh&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/wiki:v0.2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
=== Permission Issues ===&lt;br /&gt;
If you encounter permission errors with the shared directory:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Fix permissions on host&lt;br /&gt;
chmod 777 ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Import Failures ===&lt;br /&gt;
If imports fail, check:&lt;br /&gt;
* File exists in ~/wiki-container&lt;br /&gt;
* File is valid XML format&lt;br /&gt;
* Sufficient disk space&lt;br /&gt;
* Check debug log: &amp;lt;code&amp;gt;docker exec completenoobs_wiki tail /tmp/mediawiki-debug.log&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Container Won&#039;t Start ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check logs&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&lt;br /&gt;
# Try interactive mode&lt;br /&gt;
docker run -it --rm completenoobs/wiki:v0.2 bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
This setup provides:&lt;br /&gt;
* Clean MediaWiki 1.44 installation&lt;br /&gt;
* Essential extensions (PageNotice, YouTube, SyntaxHighlight)&lt;br /&gt;
* Simple import/export via ~/wiki-container directory&lt;br /&gt;
* No automatic updates - full control over your content&lt;br /&gt;
* Easy backup and restore capabilities&lt;br /&gt;
&lt;br /&gt;
The shared directory approach gives you complete control over when and what to import/export, making it ideal for:&lt;br /&gt;
* Migrating content between wikis&lt;br /&gt;
* Regular backups&lt;br /&gt;
* Sharing wiki content&lt;br /&gt;
* Testing imports before applying to production&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_2&amp;diff=677</id>
		<title>CompleteNoobs Docker Image 2</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_2&amp;diff=677"/>
		<updated>2025-09-02T14:42:36Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* 5.2: Test Extensions */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;br /&gt;
==todo==&lt;br /&gt;
* Use Dir for import export of xml.&lt;br /&gt;
* Min scripts - just setup mediawiki with extensions.&lt;br /&gt;
&lt;br /&gt;
= Complete Noobs Docker Wiki Tutorial v0.2 =&lt;br /&gt;
* Version 0.2 - Simplified with manual import/export&lt;br /&gt;
* [[Docker_Install_Guide| Docker install guide]]&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
This tutorial creates a MediaWiki Docker container with:&lt;br /&gt;
* MediaWiki 1.44&lt;br /&gt;
* PageNotice extension (for license notices)&lt;br /&gt;
* YouTube extension (for video embedding)&lt;br /&gt;
* Shared directory for XML import/export&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
* Ubuntu 24.04&lt;br /&gt;
* Docker installed and running&lt;br /&gt;
* Your user in docker group: &amp;lt;code&amp;gt;sudo usermod -aG docker $USER&amp;lt;/code&amp;gt; (then logout/login)&lt;br /&gt;
&lt;br /&gt;
== Step 1: Create Directory Structure ==&lt;br /&gt;
&lt;br /&gt;
=== 1.1: Create Project Directory ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir ~/completenoobs-docker-v2&lt;br /&gt;
cd ~/completenoobs-docker-v2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 1.2: Create Shared Directory ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This directory will be used for importing and exporting XML files.&lt;br /&gt;
&lt;br /&gt;
== Step 2: Create All Files ==&lt;br /&gt;
&lt;br /&gt;
=== 2.1: Dockerfile ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano Dockerfile&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;dockerfile&amp;quot;&amp;gt;&lt;br /&gt;
FROM mediawiki:1.44&lt;br /&gt;
&lt;br /&gt;
# Install dependencies&lt;br /&gt;
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \&lt;br /&gt;
    mariadb-server \&lt;br /&gt;
    python3-pygments \&lt;br /&gt;
    curl \&lt;br /&gt;
    wget \&lt;br /&gt;
    unzip \&lt;br /&gt;
    nano \&lt;br /&gt;
    vim \&lt;br /&gt;
    git \&lt;br /&gt;
    &amp;amp;&amp;amp; apt-get clean&lt;br /&gt;
&lt;br /&gt;
# Copy setup script&lt;br /&gt;
COPY setup_wiki.sh /usr/src/setup_wiki.sh&lt;br /&gt;
COPY entrypoint.sh /entrypoint.sh&lt;br /&gt;
&lt;br /&gt;
# Make executable&lt;br /&gt;
RUN chmod +x /usr/src/setup_wiki.sh /entrypoint.sh&lt;br /&gt;
&lt;br /&gt;
# Setup wiki&lt;br /&gt;
RUN /usr/src/setup_wiki.sh&lt;br /&gt;
&lt;br /&gt;
EXPOSE 80&lt;br /&gt;
VOLUME /var/lib/mysql&lt;br /&gt;
VOLUME /var/www/html/images&lt;br /&gt;
VOLUME /export&lt;br /&gt;
&lt;br /&gt;
ENTRYPOINT [&amp;quot;/entrypoint.sh&amp;quot;]&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.2: Main Setup Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano setup_wiki.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Setting up CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Initialize MariaDB&lt;br /&gt;
if [ ! -d &amp;quot;/var/lib/mysql/mysql&amp;quot; ]; then&lt;br /&gt;
    mysql_install_db --user=mysql --datadir=/var/lib/mysql&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 2&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
# Setup database&lt;br /&gt;
mysql -e &amp;quot;CREATE DATABASE IF NOT EXISTS completenoobs_wiki CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;127.0.0.1&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;127.0.0.1&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;localhost&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;localhost&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;FLUSH PRIVILEGES;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Install MediaWiki&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
php maintenance/install.php \&lt;br /&gt;
    --dbtype=mysql \&lt;br /&gt;
    --dbserver=127.0.0.1 \&lt;br /&gt;
    --dbname=completenoobs_wiki \&lt;br /&gt;
    --dbuser=wikiuser \&lt;br /&gt;
    --dbpass=wikipass \&lt;br /&gt;
    --server=&amp;quot;http://localhost:8080&amp;quot; \&lt;br /&gt;
    --scriptpath=&amp;quot;&amp;quot; \&lt;br /&gt;
    --lang=en \&lt;br /&gt;
    --pass=AdminPass123! \&lt;br /&gt;
    &amp;quot;CompleteNoobs Wiki&amp;quot; \&lt;br /&gt;
    &amp;quot;admin&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Download and install extensions&lt;br /&gt;
cd extensions/&lt;br /&gt;
echo &amp;quot;Installing PageNotice extension...&amp;quot;&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/PageNotice --branch REL1_44 || echo &amp;quot;PageNotice download failed, continuing...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Installing YouTube extension...&amp;quot;&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/YouTube --branch REL1_44 || echo &amp;quot;YouTube download failed, continuing...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
&lt;br /&gt;
# Configure LocalSettings.php&lt;br /&gt;
cat &amp;gt;&amp;gt; LocalSettings.php &amp;lt;&amp;lt; &#039;EOF&#039;&lt;br /&gt;
&lt;br /&gt;
# Basic settings&lt;br /&gt;
$wgEnableUploads = true;&lt;br /&gt;
$wgUseImageMagick = true;&lt;br /&gt;
$wgImageMagickConvertCommand = &amp;quot;/usr/bin/convert&amp;quot;;&lt;br /&gt;
$wgDefaultSkin = &amp;quot;vector-2022&amp;quot;;&lt;br /&gt;
$wgAllowExternalImages = true;&lt;br /&gt;
&lt;br /&gt;
# Debug settings (remove in production)&lt;br /&gt;
$wgShowExceptionDetails = true;&lt;br /&gt;
$wgDebugLogFile = &amp;quot;/tmp/mediawiki-debug.log&amp;quot;;&lt;br /&gt;
&lt;br /&gt;
# PageNotice extension&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/PageNotice/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;PageNotice&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# YouTube extension&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/YouTube/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;YouTube&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# SyntaxHighlight (usually bundled)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/SyntaxHighlight_GeSHi/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;SyntaxHighlight_GeSHi&#039; );&lt;br /&gt;
    $wgPygmentizePath = &#039;/usr/bin/pygmentize&#039;;&lt;br /&gt;
}&lt;br /&gt;
EOF&lt;br /&gt;
&lt;br /&gt;
# Create helper scripts&lt;br /&gt;
cat &amp;gt; /var/www/html/export_wiki.sh &amp;lt;&amp;lt; &#039;EXPORT_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
echo &amp;quot;=== Wiki Export Tool ===&amp;quot;&lt;br /&gt;
DATE=$(date +%Y%m%d)&lt;br /&gt;
OUTPUT_FILE=&amp;quot;/export/${DATE}_wiki_export.xml&amp;quot;&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Exporting wiki to: $OUTPUT_FILE&amp;quot;&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:$OUTPUT_FILE&lt;br /&gt;
&lt;br /&gt;
if [ -f &amp;quot;$OUTPUT_FILE&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Export successful!&amp;quot;&lt;br /&gt;
    echo &amp;quot;File saved to: $OUTPUT_FILE&amp;quot;&lt;br /&gt;
    echo &amp;quot;On host system: ~/wiki-container/${DATE}_wiki_export.xml&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;Export failed!&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
EXPORT_EOF&lt;br /&gt;
chmod +x /var/www/html/export_wiki.sh&lt;br /&gt;
&lt;br /&gt;
cat &amp;gt; /var/www/html/import_wiki.sh &amp;lt;&amp;lt; &#039;IMPORT_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
echo &amp;quot;=== Wiki Import Tool ===&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Available XML files in /export:&amp;quot;&lt;br /&gt;
ls -la /export/*.xml 2&amp;gt;/dev/null || echo &amp;quot;No XML files found&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
if [ -z &amp;quot;$1&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Usage: /var/www/html/import_wiki.sh &amp;lt;filename&amp;gt;&amp;quot;&lt;br /&gt;
    echo &amp;quot;Example: /var/www/html/import_wiki.sh /export/wiki.xml&amp;quot;&lt;br /&gt;
    exit 1&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
if [ ! -f &amp;quot;$1&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Error: File $1 not found!&amp;quot;&lt;br /&gt;
    exit 1&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Importing from: $1&amp;quot;&lt;br /&gt;
echo &amp;quot;This may take several minutes...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php &amp;quot;$1&amp;quot;&lt;br /&gt;
&lt;br /&gt;
if [ $? -eq 0 ]; then&lt;br /&gt;
    echo &amp;quot;Import completed!&amp;quot;&lt;br /&gt;
    echo &amp;quot;Rebuilding indexes...&amp;quot;&lt;br /&gt;
    php /var/www/html/maintenance/run.php rebuildrecentchanges.php&lt;br /&gt;
    php /var/www/html/maintenance/run.php initSiteStats.php&lt;br /&gt;
    echo &amp;quot;Done!&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;Import failed! Check /tmp/mediawiki-debug.log for details&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
IMPORT_EOF&lt;br /&gt;
chmod +x /var/www/html/import_wiki.sh&lt;br /&gt;
&lt;br /&gt;
# Create status check script&lt;br /&gt;
cat &amp;gt; /var/www/html/check_status.sh &amp;lt;&amp;lt; &#039;STATUS_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
echo &amp;quot;=== Wiki Status ===&amp;quot;&lt;br /&gt;
echo &amp;quot;Pages: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;Users: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM user WHERE user_id &amp;gt; 0;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Extensions ===&amp;quot;&lt;br /&gt;
if [ -d &amp;quot;extensions/PageNotice&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;PageNotice: Installed ✓&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;PageNotice: Not installed ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/YouTube&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;YouTube: Installed ✓&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;YouTube: Not installed ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/SyntaxHighlight_GeSHi&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;SyntaxHighlight: Installed ✓&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;SyntaxHighlight: Not installed ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Shared Directory ===&amp;quot;&lt;br /&gt;
if [ -d &amp;quot;/export&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;/export mounted ✓&amp;quot;&lt;br /&gt;
    echo &amp;quot;Files in /export:&amp;quot;&lt;br /&gt;
    ls -la /export/*.xml 2&amp;gt;/dev/null || echo &amp;quot;  No XML files&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;/export not mounted ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
STATUS_EOF&lt;br /&gt;
chmod +x /var/www/html/check_status.sh&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Setup completed!&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin: admin / AdminPass123!&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb stop&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.3: Entrypoint Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano entrypoint.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Starting CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 1&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=========================================&amp;quot;&lt;br /&gt;
echo &amp;quot;CompleteNoobs Wiki v0.2 Ready!&amp;quot;&lt;br /&gt;
echo &amp;quot;=========================================&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Access: http://localhost:8080&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Features:&amp;quot;&lt;br /&gt;
echo &amp;quot;- MediaWiki 1.44&amp;quot;&lt;br /&gt;
echo &amp;quot;- PageNotice extension&amp;quot;&lt;br /&gt;
echo &amp;quot;- YouTube extension&amp;quot;&lt;br /&gt;
echo &amp;quot;- SyntaxHighlight extension&amp;quot;&lt;br /&gt;
echo &amp;quot;- Import/Export via ~/wiki-container&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Available commands:&amp;quot;&lt;br /&gt;
echo &amp;quot;- Export wiki: docker exec completenoobs_wiki /var/www/html/export_wiki.sh&amp;quot;&lt;br /&gt;
echo &amp;quot;- Import XML: docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/filename.xml&amp;quot;&lt;br /&gt;
echo &amp;quot;- Check status: docker exec completenoobs_wiki /var/www/html/check_status.sh&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
apache2-foreground&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 3: Build and Run ==&lt;br /&gt;
&lt;br /&gt;
=== 3.1: Build the Image ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker build -t completenoobs/wiki:v0.2 .&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3.2: Run the Container with Shared Directory ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/wiki:v0.2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 4: Import/Export Operations ==&lt;br /&gt;
&lt;br /&gt;
=== 4.1: Import an XML File ===&lt;br /&gt;
* https://xml.completenoobs.com/xmlDumps/&lt;br /&gt;
==== Place XML file in shared directory ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Copy your XML file to the shared directory&lt;br /&gt;
cp ~/Downloads/completenoobs.xml ~/wiki-container/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Import the file ====&lt;br /&gt;
* All files in host &amp;lt;code&amp;gt;wiki-container&amp;lt;/code&amp;gt; directory will also be in the containers &amp;lt;code&amp;gt;/export/&amp;lt;/code&amp;gt; directory.&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Run the import command&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/completenoobs.xml&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{:Restore_the_completenoobs_Main_Page}}&lt;br /&gt;
&lt;br /&gt;
=== 4.2: Export Your Wiki ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Export wiki to dated XML file&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/export_wiki.sh&lt;br /&gt;
&lt;br /&gt;
# Check the exported file&lt;br /&gt;
ls -la ~/wiki-container/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.3: Manual Import/Export (Advanced) ===&lt;br /&gt;
&lt;br /&gt;
==== Access container shell ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Manual export with custom filename ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:/export/my_wiki_backup.xml&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Manual import with options ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Basic import&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php /export/wiki.xml&lt;br /&gt;
&lt;br /&gt;
# Import with image uploads&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php --uploads /export/wiki.xml&lt;br /&gt;
&lt;br /&gt;
# Then rebuild indexes&lt;br /&gt;
php /var/www/html/maintenance/run.php rebuildall.php&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 5: Testing and Verification ==&lt;br /&gt;
&lt;br /&gt;
=== 5.1: Check Wiki Status ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/check_status.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5.2: Test Extensions ===&lt;br /&gt;
1. Visit http://localhost:8080&lt;br /&gt;
2. Login with admin / AdminPass123!&lt;br /&gt;
3. Create or edit a page and test:&lt;br /&gt;
   * YouTube: Add &amp;lt;code&amp;gt;&amp;lt;youtube&amp;gt;gBML6zuUpK0&amp;lt;/youtube&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
   * SyntaxHighlight: Add &amp;lt;nowiki&amp;gt;&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;print(&amp;quot;Hello&amp;quot;)&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;/nowiki&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5.3: View Logs ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Container logs&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&lt;br /&gt;
# MediaWiki debug log&lt;br /&gt;
docker exec completenoobs_wiki tail -f /tmp/mediawiki-debug.log&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 6: Common Operations ==&lt;br /&gt;
&lt;br /&gt;
=== 6.1: Change Admin Password ===&lt;br /&gt;
&lt;br /&gt;
==== Via Web Interface ====&lt;br /&gt;
1. Login at http://localhost:8080&lt;br /&gt;
2. Click username → Preferences&lt;br /&gt;
3. Go to Password tab&lt;br /&gt;
4. Change password&lt;br /&gt;
&lt;br /&gt;
==== Via Terminal ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6.2: Backup Your Wiki ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Create dated backup&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/export_wiki.sh&lt;br /&gt;
&lt;br /&gt;
# Copy to safe location&lt;br /&gt;
cp ~/wiki-container/*_wiki_export.xml ~/backups/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6.3: Restore from Backup ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Place backup in shared directory&lt;br /&gt;
cp ~/backups/20250101_wiki_export.xml ~/wiki-container/&lt;br /&gt;
&lt;br /&gt;
# Import the backup&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/20250101_wiki_export.xml&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6.4: Complete Reset ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Stop and remove container&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
&lt;br /&gt;
# Remove volumes (WARNING: This deletes all data!)&lt;br /&gt;
docker volume rm completenoobs_mysql completenoobs_images&lt;br /&gt;
&lt;br /&gt;
# Rebuild and start fresh&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/wiki:v0.2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
=== Permission Issues ===&lt;br /&gt;
If you encounter permission errors with the shared directory:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Fix permissions on host&lt;br /&gt;
chmod 777 ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Import Failures ===&lt;br /&gt;
If imports fail, check:&lt;br /&gt;
* File exists in ~/wiki-container&lt;br /&gt;
* File is valid XML format&lt;br /&gt;
* Sufficient disk space&lt;br /&gt;
* Check debug log: &amp;lt;code&amp;gt;docker exec completenoobs_wiki tail /tmp/mediawiki-debug.log&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Container Won&#039;t Start ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check logs&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&lt;br /&gt;
# Try interactive mode&lt;br /&gt;
docker run -it --rm completenoobs/wiki:v0.2 bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
This setup provides:&lt;br /&gt;
* Clean MediaWiki 1.44 installation&lt;br /&gt;
* Essential extensions (PageNotice, YouTube, SyntaxHighlight)&lt;br /&gt;
* Simple import/export via ~/wiki-container directory&lt;br /&gt;
* No automatic updates - full control over your content&lt;br /&gt;
* Easy backup and restore capabilities&lt;br /&gt;
&lt;br /&gt;
The shared directory approach gives you complete control over when and what to import/export, making it ideal for:&lt;br /&gt;
* Migrating content between wikis&lt;br /&gt;
* Regular backups&lt;br /&gt;
* Sharing wiki content&lt;br /&gt;
* Testing imports before applying to production&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Install&amp;diff=676</id>
		<title>CompleteNoobs Docker Image Install</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Install&amp;diff=676"/>
		<updated>2025-09-02T14:36:54Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;br /&gt;
* [[CompleteNoobs_Docker_Image_0.2_Install_Basics| cnoobs-wiki 0.2 basic install guide]]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_0.2_Install_Basics&amp;diff=675</id>
		<title>CompleteNoobs Docker Image 0.2 Install Basics</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_0.2_Install_Basics&amp;diff=675"/>
		<updated>2025-09-02T14:31:39Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Create directory for file sharing */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;br /&gt;
* Requires docker installed on system&lt;br /&gt;
&lt;br /&gt;
== Download image==&lt;br /&gt;
* Download the completenoobs container image.&lt;br /&gt;
&amp;lt;code&amp;gt;docker pull completenoobs/cnoobs-wiki:0.2&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Create directory for file sharing ==&lt;br /&gt;
* If you do not create this directory - docker will create one for you when you run the &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; command.&lt;br /&gt;
** If docker run creates the directory, you will not have permissions to send data to that directory.&lt;br /&gt;
** Change permission&#039;s if that happens &amp;lt;code&amp;gt;sudo chmod 777 ~/wiki-container&amp;lt;/code&amp;gt;&lt;br /&gt;
&amp;lt;code&amp;gt;mkdir ~/wiki-container&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Run image ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Download XMl ==&lt;br /&gt;
&lt;br /&gt;
* https://xml.completenoobs.com/xmlDumps/&lt;br /&gt;
Download an xml file, like:&amp;lt;code&amp;gt;01_09_25.Noobs.xml&amp;lt;/code&amp;gt;&lt;br /&gt;
* Move to &amp;lt;code&amp;gt;~/wiki-container/&amp;lt;/code&amp;gt; directory&lt;br /&gt;
&lt;br /&gt;
== Import XML ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/01_09_25.Noobs.xml&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Check Wiki ==&lt;br /&gt;
* In browser go to this address&lt;br /&gt;
&amp;lt;code&amp;gt;http://localhost:8080&amp;lt;/code&amp;gt;&lt;br /&gt;
&amp;lt;br&amp;gt;&lt;br /&gt;
{{:Restore_the_completenoobs_Main_Page}}&lt;br /&gt;
&lt;br /&gt;
==Change Admin Password==&lt;br /&gt;
&lt;br /&gt;
* Can be done with &#039;Web Browser GUI&#039; or &#039;Terminal&#039;&lt;br /&gt;
* Default Password for user &amp;lt;b&amp;gt;Admin&amp;lt;/b&amp;gt; = &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;&lt;br /&gt;
=== Via Web Interface ===&lt;br /&gt;
1. Login at http://localhost:8080&lt;br /&gt;
2. Click username → Preferences&lt;br /&gt;
3. Go to Password tab&lt;br /&gt;
4. Change password&lt;br /&gt;
&lt;br /&gt;
=== Via Terminal ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Export a backup of your wiki==&lt;br /&gt;
* This will create a backup of your wiki to a dated.xml file &amp;lt;code&amp;gt;20250902_wiki_export.xml&amp;lt;/code&amp;gt;, which you can find in your &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory on host, or &amp;lt;code&amp;gt;/export/&amp;lt;/code&amp;gt; directory in container.&lt;br /&gt;
&amp;lt;code&amp;gt;docker exec completenoobs_wiki /var/www/html/export_wiki.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
==Remove container==&lt;br /&gt;
&lt;br /&gt;
To completely remove the &amp;lt;code&amp;gt;completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; container and image from your Ubuntu 24.04 system, follow these steps. &amp;lt;br&amp;gt;&lt;br /&gt;
You can also remove associated persistent storage volumes if they were created.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Stop and Remove the Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If the container is running, stop it and then remove it.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Alternatively, stop and remove in one command:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker rm -f completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Remove the Docker Image&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Remove the &amp;lt;code&amp;gt;completenoobs/cnoobs-wiki:0.2&amp;lt;/code&amp;gt; image.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker rmi completenoobs/cnoobs-wiki:0.2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Note: If the image is in use by other containers, remove those containers first or use &amp;lt;code&amp;gt;docker rmi -f completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; to force removal (use with caution).&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Remove Persistent Storage Volumes (Optional)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you used persistent storage, remove the associated volumes to free up space.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker volume rm completenoobs_mysql completenoobs_images&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Note: Ensure no other containers are using these volumes, as this will delete all stored data.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify Removal&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Check that the container, image, and volumes are removed.&amp;lt;br&amp;gt;&lt;br /&gt;
- List all containers (including stopped ones):&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker ps -a&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- List all images:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker images&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- List all volumes:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker volume ls&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
If any items remain, repeat the relevant removal commands or check for dependencies.&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_0.2_Install_Basics&amp;diff=674</id>
		<title>CompleteNoobs Docker Image 0.2 Install Basics</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_0.2_Install_Basics&amp;diff=674"/>
		<updated>2025-09-02T14:31:06Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot; * Requires docker installed on system  == Download image== * Download the completenoobs container image. &amp;lt;code&amp;gt;docker pull completenoobs/cnoobs-wiki:0.2&amp;lt;/code&amp;gt;  == Create directory for file sharing == * If you do not create this file - docker will create one for you when you run the &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; command. ** If docker run creates the file, you will not have permissions to send data to that directory. ** Change permission&amp;#039;s if that happens &amp;lt;code&amp;gt;sudo chmod 777...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;br /&gt;
* Requires docker installed on system&lt;br /&gt;
&lt;br /&gt;
== Download image==&lt;br /&gt;
* Download the completenoobs container image.&lt;br /&gt;
&amp;lt;code&amp;gt;docker pull completenoobs/cnoobs-wiki:0.2&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Create directory for file sharing ==&lt;br /&gt;
* If you do not create this file - docker will create one for you when you run the &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; command.&lt;br /&gt;
** If docker run creates the file, you will not have permissions to send data to that directory.&lt;br /&gt;
** Change permission&#039;s if that happens &amp;lt;code&amp;gt;sudo chmod 777 ~/wiki-container&amp;lt;/code&amp;gt;&lt;br /&gt;
&amp;lt;code&amp;gt;mkdir ~/wiki-container&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Run image ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.2&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Download XMl ==&lt;br /&gt;
&lt;br /&gt;
* https://xml.completenoobs.com/xmlDumps/&lt;br /&gt;
Download an xml file, like:&amp;lt;code&amp;gt;01_09_25.Noobs.xml&amp;lt;/code&amp;gt;&lt;br /&gt;
* Move to &amp;lt;code&amp;gt;~/wiki-container/&amp;lt;/code&amp;gt; directory&lt;br /&gt;
&lt;br /&gt;
== Import XML ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/01_09_25.Noobs.xml&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Check Wiki ==&lt;br /&gt;
* In browser go to this address&lt;br /&gt;
&amp;lt;code&amp;gt;http://localhost:8080&amp;lt;/code&amp;gt;&lt;br /&gt;
&amp;lt;br&amp;gt;&lt;br /&gt;
{{:Restore_the_completenoobs_Main_Page}}&lt;br /&gt;
&lt;br /&gt;
==Change Admin Password==&lt;br /&gt;
&lt;br /&gt;
* Can be done with &#039;Web Browser GUI&#039; or &#039;Terminal&#039;&lt;br /&gt;
* Default Password for user &amp;lt;b&amp;gt;Admin&amp;lt;/b&amp;gt; = &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;&lt;br /&gt;
=== Via Web Interface ===&lt;br /&gt;
1. Login at http://localhost:8080&lt;br /&gt;
2. Click username → Preferences&lt;br /&gt;
3. Go to Password tab&lt;br /&gt;
4. Change password&lt;br /&gt;
&lt;br /&gt;
=== Via Terminal ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Export a backup of your wiki==&lt;br /&gt;
* This will create a backup of your wiki to a dated.xml file &amp;lt;code&amp;gt;20250902_wiki_export.xml&amp;lt;/code&amp;gt;, which you can find in your &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory on host, or &amp;lt;code&amp;gt;/export/&amp;lt;/code&amp;gt; directory in container.&lt;br /&gt;
&amp;lt;code&amp;gt;docker exec completenoobs_wiki /var/www/html/export_wiki.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
==Remove container==&lt;br /&gt;
&lt;br /&gt;
To completely remove the &amp;lt;code&amp;gt;completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; container and image from your Ubuntu 24.04 system, follow these steps. &amp;lt;br&amp;gt;&lt;br /&gt;
You can also remove associated persistent storage volumes if they were created.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Stop and Remove the Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If the container is running, stop it and then remove it.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Alternatively, stop and remove in one command:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker rm -f completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Remove the Docker Image&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Remove the &amp;lt;code&amp;gt;completenoobs/cnoobs-wiki:0.2&amp;lt;/code&amp;gt; image.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker rmi completenoobs/cnoobs-wiki:0.2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Note: If the image is in use by other containers, remove those containers first or use &amp;lt;code&amp;gt;docker rmi -f completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; to force removal (use with caution).&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Remove Persistent Storage Volumes (Optional)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you used persistent storage, remove the associated volumes to free up space.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker volume rm completenoobs_mysql completenoobs_images&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Note: Ensure no other containers are using these volumes, as this will delete all stored data.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify Removal&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Check that the container, image, and volumes are removed.&amp;lt;br&amp;gt;&lt;br /&gt;
- List all containers (including stopped ones):&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker ps -a&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- List all images:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker images&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- List all volumes:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker volume ls&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
If any items remain, repeat the relevant removal commands or check for dependencies.&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Install&amp;diff=673</id>
		<title>CompleteNoobs Docker Image Install</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Install&amp;diff=673"/>
		<updated>2025-09-02T12:27:21Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;placeholder&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;placeholder&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_2&amp;diff=672</id>
		<title>CompleteNoobs Docker Image 2</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_2&amp;diff=672"/>
		<updated>2025-09-02T12:24:39Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot; ==todo== * Use Dir for import export of xml. * Min scripts - just setup mediawiki with extensions.  = Complete Noobs Docker Wiki Tutorial v0.2 = * Version 0.2 - Simplified with manual import/export *  Docker install guide  == Overview == This tutorial creates a MediaWiki Docker container with: * MediaWiki 1.44 * PageNotice extension (for license notices) * YouTube extension (for video embedding) * Shared directory for XML import/export  == Prere...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;br /&gt;
==todo==&lt;br /&gt;
* Use Dir for import export of xml.&lt;br /&gt;
* Min scripts - just setup mediawiki with extensions.&lt;br /&gt;
&lt;br /&gt;
= Complete Noobs Docker Wiki Tutorial v0.2 =&lt;br /&gt;
* Version 0.2 - Simplified with manual import/export&lt;br /&gt;
* [[Docker_Install_Guide| Docker install guide]]&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
This tutorial creates a MediaWiki Docker container with:&lt;br /&gt;
* MediaWiki 1.44&lt;br /&gt;
* PageNotice extension (for license notices)&lt;br /&gt;
* YouTube extension (for video embedding)&lt;br /&gt;
* Shared directory for XML import/export&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
* Ubuntu 24.04&lt;br /&gt;
* Docker installed and running&lt;br /&gt;
* Your user in docker group: &amp;lt;code&amp;gt;sudo usermod -aG docker $USER&amp;lt;/code&amp;gt; (then logout/login)&lt;br /&gt;
&lt;br /&gt;
== Step 1: Create Directory Structure ==&lt;br /&gt;
&lt;br /&gt;
=== 1.1: Create Project Directory ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir ~/completenoobs-docker-v2&lt;br /&gt;
cd ~/completenoobs-docker-v2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 1.2: Create Shared Directory ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This directory will be used for importing and exporting XML files.&lt;br /&gt;
&lt;br /&gt;
== Step 2: Create All Files ==&lt;br /&gt;
&lt;br /&gt;
=== 2.1: Dockerfile ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano Dockerfile&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;dockerfile&amp;quot;&amp;gt;&lt;br /&gt;
FROM mediawiki:1.44&lt;br /&gt;
&lt;br /&gt;
# Install dependencies&lt;br /&gt;
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \&lt;br /&gt;
    mariadb-server \&lt;br /&gt;
    python3-pygments \&lt;br /&gt;
    curl \&lt;br /&gt;
    wget \&lt;br /&gt;
    unzip \&lt;br /&gt;
    nano \&lt;br /&gt;
    vim \&lt;br /&gt;
    git \&lt;br /&gt;
    &amp;amp;&amp;amp; apt-get clean&lt;br /&gt;
&lt;br /&gt;
# Copy setup script&lt;br /&gt;
COPY setup_wiki.sh /usr/src/setup_wiki.sh&lt;br /&gt;
COPY entrypoint.sh /entrypoint.sh&lt;br /&gt;
&lt;br /&gt;
# Make executable&lt;br /&gt;
RUN chmod +x /usr/src/setup_wiki.sh /entrypoint.sh&lt;br /&gt;
&lt;br /&gt;
# Setup wiki&lt;br /&gt;
RUN /usr/src/setup_wiki.sh&lt;br /&gt;
&lt;br /&gt;
EXPOSE 80&lt;br /&gt;
VOLUME /var/lib/mysql&lt;br /&gt;
VOLUME /var/www/html/images&lt;br /&gt;
VOLUME /export&lt;br /&gt;
&lt;br /&gt;
ENTRYPOINT [&amp;quot;/entrypoint.sh&amp;quot;]&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.2: Main Setup Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano setup_wiki.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Setting up CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Initialize MariaDB&lt;br /&gt;
if [ ! -d &amp;quot;/var/lib/mysql/mysql&amp;quot; ]; then&lt;br /&gt;
    mysql_install_db --user=mysql --datadir=/var/lib/mysql&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 2&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
# Setup database&lt;br /&gt;
mysql -e &amp;quot;CREATE DATABASE IF NOT EXISTS completenoobs_wiki CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;127.0.0.1&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;127.0.0.1&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;localhost&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;localhost&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;FLUSH PRIVILEGES;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Install MediaWiki&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
php maintenance/install.php \&lt;br /&gt;
    --dbtype=mysql \&lt;br /&gt;
    --dbserver=127.0.0.1 \&lt;br /&gt;
    --dbname=completenoobs_wiki \&lt;br /&gt;
    --dbuser=wikiuser \&lt;br /&gt;
    --dbpass=wikipass \&lt;br /&gt;
    --server=&amp;quot;http://localhost:8080&amp;quot; \&lt;br /&gt;
    --scriptpath=&amp;quot;&amp;quot; \&lt;br /&gt;
    --lang=en \&lt;br /&gt;
    --pass=AdminPass123! \&lt;br /&gt;
    &amp;quot;CompleteNoobs Wiki&amp;quot; \&lt;br /&gt;
    &amp;quot;admin&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Download and install extensions&lt;br /&gt;
cd extensions/&lt;br /&gt;
echo &amp;quot;Installing PageNotice extension...&amp;quot;&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/PageNotice --branch REL1_44 || echo &amp;quot;PageNotice download failed, continuing...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Installing YouTube extension...&amp;quot;&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/YouTube --branch REL1_44 || echo &amp;quot;YouTube download failed, continuing...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
&lt;br /&gt;
# Configure LocalSettings.php&lt;br /&gt;
cat &amp;gt;&amp;gt; LocalSettings.php &amp;lt;&amp;lt; &#039;EOF&#039;&lt;br /&gt;
&lt;br /&gt;
# Basic settings&lt;br /&gt;
$wgEnableUploads = true;&lt;br /&gt;
$wgUseImageMagick = true;&lt;br /&gt;
$wgImageMagickConvertCommand = &amp;quot;/usr/bin/convert&amp;quot;;&lt;br /&gt;
$wgDefaultSkin = &amp;quot;vector-2022&amp;quot;;&lt;br /&gt;
$wgAllowExternalImages = true;&lt;br /&gt;
&lt;br /&gt;
# Debug settings (remove in production)&lt;br /&gt;
$wgShowExceptionDetails = true;&lt;br /&gt;
$wgDebugLogFile = &amp;quot;/tmp/mediawiki-debug.log&amp;quot;;&lt;br /&gt;
&lt;br /&gt;
# PageNotice extension&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/PageNotice/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;PageNotice&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# YouTube extension&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/YouTube/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;YouTube&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# SyntaxHighlight (usually bundled)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/SyntaxHighlight_GeSHi/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;SyntaxHighlight_GeSHi&#039; );&lt;br /&gt;
    $wgPygmentizePath = &#039;/usr/bin/pygmentize&#039;;&lt;br /&gt;
}&lt;br /&gt;
EOF&lt;br /&gt;
&lt;br /&gt;
# Create helper scripts&lt;br /&gt;
cat &amp;gt; /var/www/html/export_wiki.sh &amp;lt;&amp;lt; &#039;EXPORT_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
echo &amp;quot;=== Wiki Export Tool ===&amp;quot;&lt;br /&gt;
DATE=$(date +%Y%m%d)&lt;br /&gt;
OUTPUT_FILE=&amp;quot;/export/${DATE}_wiki_export.xml&amp;quot;&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Exporting wiki to: $OUTPUT_FILE&amp;quot;&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:$OUTPUT_FILE&lt;br /&gt;
&lt;br /&gt;
if [ -f &amp;quot;$OUTPUT_FILE&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Export successful!&amp;quot;&lt;br /&gt;
    echo &amp;quot;File saved to: $OUTPUT_FILE&amp;quot;&lt;br /&gt;
    echo &amp;quot;On host system: ~/wiki-container/${DATE}_wiki_export.xml&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;Export failed!&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
EXPORT_EOF&lt;br /&gt;
chmod +x /var/www/html/export_wiki.sh&lt;br /&gt;
&lt;br /&gt;
cat &amp;gt; /var/www/html/import_wiki.sh &amp;lt;&amp;lt; &#039;IMPORT_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
echo &amp;quot;=== Wiki Import Tool ===&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Available XML files in /export:&amp;quot;&lt;br /&gt;
ls -la /export/*.xml 2&amp;gt;/dev/null || echo &amp;quot;No XML files found&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
if [ -z &amp;quot;$1&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Usage: /var/www/html/import_wiki.sh &amp;lt;filename&amp;gt;&amp;quot;&lt;br /&gt;
    echo &amp;quot;Example: /var/www/html/import_wiki.sh /export/wiki.xml&amp;quot;&lt;br /&gt;
    exit 1&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
if [ ! -f &amp;quot;$1&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Error: File $1 not found!&amp;quot;&lt;br /&gt;
    exit 1&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Importing from: $1&amp;quot;&lt;br /&gt;
echo &amp;quot;This may take several minutes...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php &amp;quot;$1&amp;quot;&lt;br /&gt;
&lt;br /&gt;
if [ $? -eq 0 ]; then&lt;br /&gt;
    echo &amp;quot;Import completed!&amp;quot;&lt;br /&gt;
    echo &amp;quot;Rebuilding indexes...&amp;quot;&lt;br /&gt;
    php /var/www/html/maintenance/run.php rebuildrecentchanges.php&lt;br /&gt;
    php /var/www/html/maintenance/run.php initSiteStats.php&lt;br /&gt;
    echo &amp;quot;Done!&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;Import failed! Check /tmp/mediawiki-debug.log for details&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
IMPORT_EOF&lt;br /&gt;
chmod +x /var/www/html/import_wiki.sh&lt;br /&gt;
&lt;br /&gt;
# Create status check script&lt;br /&gt;
cat &amp;gt; /var/www/html/check_status.sh &amp;lt;&amp;lt; &#039;STATUS_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
echo &amp;quot;=== Wiki Status ===&amp;quot;&lt;br /&gt;
echo &amp;quot;Pages: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;Users: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM user WHERE user_id &amp;gt; 0;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Extensions ===&amp;quot;&lt;br /&gt;
if [ -d &amp;quot;extensions/PageNotice&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;PageNotice: Installed ✓&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;PageNotice: Not installed ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/YouTube&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;YouTube: Installed ✓&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;YouTube: Not installed ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/SyntaxHighlight_GeSHi&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;SyntaxHighlight: Installed ✓&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;SyntaxHighlight: Not installed ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Shared Directory ===&amp;quot;&lt;br /&gt;
if [ -d &amp;quot;/export&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;/export mounted ✓&amp;quot;&lt;br /&gt;
    echo &amp;quot;Files in /export:&amp;quot;&lt;br /&gt;
    ls -la /export/*.xml 2&amp;gt;/dev/null || echo &amp;quot;  No XML files&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;/export not mounted ✗&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
STATUS_EOF&lt;br /&gt;
chmod +x /var/www/html/check_status.sh&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Setup completed!&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin: admin / AdminPass123!&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb stop&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.3: Entrypoint Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano entrypoint.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Starting CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 1&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=========================================&amp;quot;&lt;br /&gt;
echo &amp;quot;CompleteNoobs Wiki v0.2 Ready!&amp;quot;&lt;br /&gt;
echo &amp;quot;=========================================&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Access: http://localhost:8080&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Features:&amp;quot;&lt;br /&gt;
echo &amp;quot;- MediaWiki 1.44&amp;quot;&lt;br /&gt;
echo &amp;quot;- PageNotice extension&amp;quot;&lt;br /&gt;
echo &amp;quot;- YouTube extension&amp;quot;&lt;br /&gt;
echo &amp;quot;- SyntaxHighlight extension&amp;quot;&lt;br /&gt;
echo &amp;quot;- Import/Export via ~/wiki-container&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Available commands:&amp;quot;&lt;br /&gt;
echo &amp;quot;- Export wiki: docker exec completenoobs_wiki /var/www/html/export_wiki.sh&amp;quot;&lt;br /&gt;
echo &amp;quot;- Import XML: docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/filename.xml&amp;quot;&lt;br /&gt;
echo &amp;quot;- Check status: docker exec completenoobs_wiki /var/www/html/check_status.sh&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
apache2-foreground&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 3: Build and Run ==&lt;br /&gt;
&lt;br /&gt;
=== 3.1: Build the Image ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker build -t completenoobs/wiki:v0.2 .&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3.2: Run the Container with Shared Directory ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/wiki:v0.2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 4: Import/Export Operations ==&lt;br /&gt;
&lt;br /&gt;
=== 4.1: Import an XML File ===&lt;br /&gt;
* https://xml.completenoobs.com/xmlDumps/&lt;br /&gt;
==== Place XML file in shared directory ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Copy your XML file to the shared directory&lt;br /&gt;
cp ~/Downloads/completenoobs.xml ~/wiki-container/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Import the file ====&lt;br /&gt;
* All files in host &amp;lt;code&amp;gt;wiki-container&amp;lt;/code&amp;gt; directory will also be in the containers &amp;lt;code&amp;gt;/export/&amp;lt;/code&amp;gt; directory.&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Run the import command&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/completenoobs.xml&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{:Restore_the_completenoobs_Main_Page}}&lt;br /&gt;
&lt;br /&gt;
=== 4.2: Export Your Wiki ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Export wiki to dated XML file&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/export_wiki.sh&lt;br /&gt;
&lt;br /&gt;
# Check the exported file&lt;br /&gt;
ls -la ~/wiki-container/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.3: Manual Import/Export (Advanced) ===&lt;br /&gt;
&lt;br /&gt;
==== Access container shell ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Manual export with custom filename ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:/export/my_wiki_backup.xml&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Manual import with options ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Basic import&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php /export/wiki.xml&lt;br /&gt;
&lt;br /&gt;
# Import with image uploads&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php --uploads /export/wiki.xml&lt;br /&gt;
&lt;br /&gt;
# Then rebuild indexes&lt;br /&gt;
php /var/www/html/maintenance/run.php rebuildall.php&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 5: Testing and Verification ==&lt;br /&gt;
&lt;br /&gt;
=== 5.1: Check Wiki Status ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/check_status.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5.2: Test Extensions ===&lt;br /&gt;
1. Visit http://localhost:8080&lt;br /&gt;
2. Login with admin / AdminPass123!&lt;br /&gt;
3. Create or edit a page and test:&lt;br /&gt;
   * YouTube: Add &amp;lt;code&amp;gt;&amp;lt;youtube&amp;gt;VIDEO_ID&amp;lt;/youtube&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
   * SyntaxHighlight: Add &amp;lt;nowiki&amp;gt;&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;print(&amp;quot;Hello&amp;quot;)&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;/nowiki&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5.3: View Logs ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Container logs&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&lt;br /&gt;
# MediaWiki debug log&lt;br /&gt;
docker exec completenoobs_wiki tail -f /tmp/mediawiki-debug.log&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 6: Common Operations ==&lt;br /&gt;
&lt;br /&gt;
=== 6.1: Change Admin Password ===&lt;br /&gt;
&lt;br /&gt;
==== Via Web Interface ====&lt;br /&gt;
1. Login at http://localhost:8080&lt;br /&gt;
2. Click username → Preferences&lt;br /&gt;
3. Go to Password tab&lt;br /&gt;
4. Change password&lt;br /&gt;
&lt;br /&gt;
==== Via Terminal ====&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6.2: Backup Your Wiki ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Create dated backup&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/export_wiki.sh&lt;br /&gt;
&lt;br /&gt;
# Copy to safe location&lt;br /&gt;
cp ~/wiki-container/*_wiki_export.xml ~/backups/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6.3: Restore from Backup ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Place backup in shared directory&lt;br /&gt;
cp ~/backups/20250101_wiki_export.xml ~/wiki-container/&lt;br /&gt;
&lt;br /&gt;
# Import the backup&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/import_wiki.sh /export/20250101_wiki_export.xml&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6.4: Complete Reset ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Stop and remove container&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
&lt;br /&gt;
# Remove volumes (WARNING: This deletes all data!)&lt;br /&gt;
docker volume rm completenoobs_mysql completenoobs_images&lt;br /&gt;
&lt;br /&gt;
# Rebuild and start fresh&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/wiki:v0.2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
=== Permission Issues ===&lt;br /&gt;
If you encounter permission errors with the shared directory:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Fix permissions on host&lt;br /&gt;
chmod 777 ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Import Failures ===&lt;br /&gt;
If imports fail, check:&lt;br /&gt;
* File exists in ~/wiki-container&lt;br /&gt;
* File is valid XML format&lt;br /&gt;
* Sufficient disk space&lt;br /&gt;
* Check debug log: &amp;lt;code&amp;gt;docker exec completenoobs_wiki tail /tmp/mediawiki-debug.log&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Container Won&#039;t Start ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check logs&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&lt;br /&gt;
# Try interactive mode&lt;br /&gt;
docker run -it --rm completenoobs/wiki:v0.2 bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
This setup provides:&lt;br /&gt;
* Clean MediaWiki 1.44 installation&lt;br /&gt;
* Essential extensions (PageNotice, YouTube, SyntaxHighlight)&lt;br /&gt;
* Simple import/export via ~/wiki-container directory&lt;br /&gt;
* No automatic updates - full control over your content&lt;br /&gt;
* Easy backup and restore capabilities&lt;br /&gt;
&lt;br /&gt;
The shared directory approach gives you complete control over when and what to import/export, making it ideal for:&lt;br /&gt;
* Migrating content between wikis&lt;br /&gt;
* Regular backups&lt;br /&gt;
* Sharing wiki content&lt;br /&gt;
* Testing imports before applying to production&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Creation&amp;diff=671</id>
		<title>CompleteNoobs Docker Image Creation</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Creation&amp;diff=671"/>
		<updated>2025-09-02T10:53:40Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Complete Noobs Docker Wiki Tutorial */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Versions =&lt;br /&gt;
* This is version 0.1 - many bugs&lt;br /&gt;
* [[todo| CompleteNoobs Docker Image 0.2]]&lt;br /&gt;
&lt;br /&gt;
= Complete Noobs Docker Wiki Tutorial =&lt;br /&gt;
* [[Docker_Install_Guide| Docker install guide]]&lt;br /&gt;
==errors==&lt;br /&gt;
* This mainly works - just need to fix the extensions popular pages and contrubtion scores&lt;br /&gt;
* The XML updater requires more work - currently idea placeholder&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
* Ubuntu 24.04&lt;br /&gt;
* Docker installed and running&lt;br /&gt;
* Your user in docker group: &amp;lt;code&amp;gt;sudo usermod -aG docker $USER&amp;lt;/code&amp;gt; (then logout/login)&lt;br /&gt;
&lt;br /&gt;
== Step 2: Create All Files ==&lt;br /&gt;
&lt;br /&gt;
=== 2.1: Dockerfile ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano Dockerfile&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Copy this exactly:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;dockerfile&amp;quot;&amp;gt;&lt;br /&gt;
FROM mediawiki:1.44&lt;br /&gt;
# Mediawiki 1.44 used over latest because can confirm extensions youtube and pagenotice works&lt;br /&gt;
# Install dependencies&lt;br /&gt;
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \&lt;br /&gt;
    mariadb-server \&lt;br /&gt;
    python3 \&lt;br /&gt;
    python3-requests \&lt;br /&gt;
    python3-bs4 \&lt;br /&gt;
    python3-pygments \&lt;br /&gt;
    curl \&lt;br /&gt;
    wget \&lt;br /&gt;
    unzip \&lt;br /&gt;
    nano \&lt;br /&gt;
    git \&lt;br /&gt;
    &amp;amp;&amp;amp; apt-get clean&lt;br /&gt;
&lt;br /&gt;
# Copy scripts&lt;br /&gt;
COPY download_latest_xml.py /usr/src/download_latest_xml.py&lt;br /&gt;
COPY setup_wiki.sh /usr/src/setup_wiki.sh&lt;br /&gt;
COPY update_xml.sh /usr/src/update_xml.sh&lt;br /&gt;
COPY entrypoint.sh /entrypoint.sh&lt;br /&gt;
&lt;br /&gt;
# Make executable&lt;br /&gt;
RUN chmod +x /usr/src/setup_wiki.sh /entrypoint.sh /usr/src/update_xml.sh&lt;br /&gt;
&lt;br /&gt;
# Download XML&lt;br /&gt;
RUN python3 /usr/src/download_latest_xml.py&lt;br /&gt;
&lt;br /&gt;
# Setup wiki&lt;br /&gt;
RUN /usr/src/setup_wiki.sh&lt;br /&gt;
&lt;br /&gt;
EXPOSE 80&lt;br /&gt;
VOLUME /var/lib/mysql&lt;br /&gt;
VOLUME /var/www/html/images&lt;br /&gt;
&lt;br /&gt;
ENTRYPOINT [&amp;quot;/entrypoint.sh&amp;quot;]&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.2: XML Download Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano download_latest_xml.py&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
import os&lt;br /&gt;
import requests&lt;br /&gt;
from bs4 import BeautifulSoup&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
BASE_URL = &amp;quot;https://xml.completenoobs.com/xmlDumps/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
def parse_date_from_dump(dump_name):&lt;br /&gt;
    match = re.match(r&#039;(\d{2})_(\d{2})_(\d{2})\.Noobs&#039;, dump_name)&lt;br /&gt;
    if match:&lt;br /&gt;
        day, month, year = match.groups()&lt;br /&gt;
        year_int = int(year)&lt;br /&gt;
        full_year = 2000 + year_int if year_int &amp;lt;= 49 else 1900 + year_int&lt;br /&gt;
        return (full_year, int(month), int(day))&lt;br /&gt;
    return (0, 0, 0)&lt;br /&gt;
&lt;br /&gt;
def get_available_dumps():&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(BASE_URL, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        dumps = [link.get(&#039;href&#039;).rstrip(&#039;/&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if re.match(r&#039;\d{2}_\d{2}_\d{2}\.Noobs/$&#039;, link.get(&#039;href&#039;, &#039;&#039;))]&lt;br /&gt;
        return sorted(dumps, key=parse_date_from_dump, reverse=True)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error fetching dumps: {e}&amp;quot;)&lt;br /&gt;
        return []&lt;br /&gt;
&lt;br /&gt;
def get_dump_files(dump):&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(f&amp;quot;{BASE_URL}{dump}/&amp;quot;, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        files = [link.get(&#039;href&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if link.get(&#039;href&#039;, &#039;&#039;).endswith(&#039;.xml&#039;)]&lt;br /&gt;
        return sorted(files, reverse=True)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error fetching dump files: {e}&amp;quot;)&lt;br /&gt;
        return []&lt;br /&gt;
&lt;br /&gt;
def download_file(url, filename):&lt;br /&gt;
    try:&lt;br /&gt;
        print(f&amp;quot;Downloading {filename}...&amp;quot;)&lt;br /&gt;
        response = requests.get(url, stream=True, timeout=60)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        &lt;br /&gt;
        total_size = int(response.headers.get(&#039;content-length&#039;, 0))&lt;br /&gt;
        downloaded = 0&lt;br /&gt;
        &lt;br /&gt;
        with open(filename, &#039;wb&#039;) as f:&lt;br /&gt;
            for chunk in response.iter_content(chunk_size=8192):&lt;br /&gt;
                if chunk:&lt;br /&gt;
                    f.write(chunk)&lt;br /&gt;
                    downloaded += len(chunk)&lt;br /&gt;
                    if total_size &amp;gt; 0:&lt;br /&gt;
                        progress = (downloaded / total_size) * 100&lt;br /&gt;
                        print(f&amp;quot;\rProgress: {progress:.1f}%&amp;quot;, end=&#039;&#039;, flush=True)&lt;br /&gt;
        print()&lt;br /&gt;
        return True&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error downloading {filename}: {e}&amp;quot;)&lt;br /&gt;
        return False&lt;br /&gt;
&lt;br /&gt;
def main():&lt;br /&gt;
    print(&amp;quot;Fetching available XML dumps...&amp;quot;)&lt;br /&gt;
    dumps = get_available_dumps()&lt;br /&gt;
    &lt;br /&gt;
    if not dumps:&lt;br /&gt;
        print(&amp;quot;No dumps found!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
    &lt;br /&gt;
    newest_dump = dumps[0]&lt;br /&gt;
    print(f&amp;quot;Latest dump: {newest_dump}&amp;quot;)&lt;br /&gt;
    &lt;br /&gt;
    files = get_dump_files(newest_dump)&lt;br /&gt;
    if not files:&lt;br /&gt;
        print(&amp;quot;No XML files found in latest dump!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
    &lt;br /&gt;
    newest_xml = files[0]&lt;br /&gt;
    xml_url = f&amp;quot;{BASE_URL}{newest_dump}/{newest_xml}&amp;quot;&lt;br /&gt;
    local_filename = &amp;quot;/tmp/completenoobs_dump.xml&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if download_file(xml_url, local_filename):&lt;br /&gt;
        print(f&amp;quot;Successfully downloaded {newest_xml}&amp;quot;)&lt;br /&gt;
        with open(&amp;quot;/tmp/dump_info.txt&amp;quot;, &amp;quot;w&amp;quot;) as f:&lt;br /&gt;
            f.write(f&amp;quot;{newest_dump}/{newest_xml}&amp;quot;)&lt;br /&gt;
    else:&lt;br /&gt;
        print(&amp;quot;Failed to download XML dump!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
&lt;br /&gt;
if __name__ == &amp;quot;__main__&amp;quot;:&lt;br /&gt;
    main()&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.3: Main Setup Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano setup_wiki.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Setting up CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Initialize MariaDB&lt;br /&gt;
if [ ! -d &amp;quot;/var/lib/mysql/mysql&amp;quot; ]; then&lt;br /&gt;
    mysql_install_db --user=mysql --datadir=/var/lib/mysql&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 2&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
# Setup database&lt;br /&gt;
mysql -e &amp;quot;CREATE DATABASE IF NOT EXISTS completenoobs_wiki CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;127.0.0.1&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;127.0.0.1&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;localhost&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;localhost&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;FLUSH PRIVILEGES;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Install MediaWiki&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
php maintenance/install.php \&lt;br /&gt;
    --dbtype=mysql \&lt;br /&gt;
    --dbserver=127.0.0.1 \&lt;br /&gt;
    --dbname=completenoobs_wiki \&lt;br /&gt;
    --dbuser=wikiuser \&lt;br /&gt;
    --dbpass=wikipass \&lt;br /&gt;
    --server=&amp;quot;http://localhost:8080&amp;quot; \&lt;br /&gt;
    --scriptpath=&amp;quot;&amp;quot; \&lt;br /&gt;
    --lang=en \&lt;br /&gt;
    --pass=AdminPass123! \&lt;br /&gt;
    &amp;quot;CompleteNoobs Wiki&amp;quot; \&lt;br /&gt;
    &amp;quot;admin&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Download and install extensions&lt;br /&gt;
cd extensions/&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/PageNotice --branch REL1_44 || echo &amp;quot;PageNotice download failed, continuing...&amp;quot;&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/YouTube --branch REL1_44 || echo &amp;quot;YouTube download failed, continuing...&amp;quot;&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
&lt;br /&gt;
# Configure LocalSettings.php&lt;br /&gt;
cat &amp;gt;&amp;gt; LocalSettings.php &amp;lt;&amp;lt; &#039;EOF&#039;&lt;br /&gt;
# Basic settings&lt;br /&gt;
$wgEnableUploads = true;&lt;br /&gt;
$wgUseImageMagick = true;&lt;br /&gt;
$wgImageMagickConvertCommand = &amp;quot;/usr/bin/convert&amp;quot;;&lt;br /&gt;
$wgDefaultSkin = &amp;quot;vector-2022&amp;quot;;&lt;br /&gt;
$wgAllowExternalImages = true;&lt;br /&gt;
&lt;br /&gt;
# Debug (can be removed later)&lt;br /&gt;
$wgShowExceptionDetails = true;&lt;br /&gt;
$wgDebugLogFile = &amp;quot;/tmp/mediawiki-debug.log&amp;quot;;&lt;br /&gt;
&lt;br /&gt;
# PageNotice extension (if available)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/PageNotice/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;PageNotice&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# YouTube extension (if available)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/YouTube/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;YouTube&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# SyntaxHighlight (usually bundled)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/SyntaxHighlight_GeSHi/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;SyntaxHighlight_GeSHi&#039; );&lt;br /&gt;
    $wgPygmentizePath = &#039;/usr/bin/pygmentize&#039;;&lt;br /&gt;
}&lt;br /&gt;
EOF&lt;br /&gt;
&lt;br /&gt;
# Import XML dump if available&lt;br /&gt;
if [ -f &amp;quot;/tmp/completenoobs_dump.xml&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Importing XML dump...&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if php maintenance/importDump.php --uploads &amp;lt; /tmp/completenoobs_dump.xml; then&lt;br /&gt;
        echo &amp;quot;XML import completed!&amp;quot;&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;XML import had warnings&amp;quot;&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Basic maintenance&lt;br /&gt;
    php maintenance/update.php --quick || echo &amp;quot;Update completed with warnings&amp;quot;&lt;br /&gt;
    php maintenance/rebuildrecentchanges.php || echo &amp;quot;RecentChanges rebuilt with warnings&amp;quot;&lt;br /&gt;
    php maintenance/initSiteStats.php || echo &amp;quot;SiteStats initialized with warnings&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if [ -f &amp;quot;/tmp/dump_info.txt&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;Import: $(cat /tmp/dump_info.txt)&amp;quot; &amp;gt; /var/www/html/.last_import&lt;br /&gt;
        echo &amp;quot;Date: $(date)&amp;quot; &amp;gt;&amp;gt; /var/www/html/.last_import&lt;br /&gt;
    fi&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;No XML dump found - starting with empty wiki&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
# Copy update script to accessible location&lt;br /&gt;
cp /usr/src/update_xml.sh /var/www/html/update_xml.sh&lt;br /&gt;
chmod +x /var/www/html/update_xml.sh&lt;br /&gt;
&lt;br /&gt;
# Create user-friendly update wrapper&lt;br /&gt;
cat &amp;gt; /var/www/html/check_updates.sh &amp;lt;&amp;lt; &#039;UPDATE_WRAPPER_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
echo &amp;quot;=== CompleteNoobs Wiki Update Checker ===&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;This tool checks for new content from the CompleteNoobs XML repository&amp;quot;&lt;br /&gt;
echo &amp;quot;and imports ONLY new pages, preserving all your local edits.&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
/var/www/html/update_xml.sh&lt;br /&gt;
UPDATE_WRAPPER_EOF&lt;br /&gt;
chmod +x /var/www/html/check_updates.sh&lt;br /&gt;
&lt;br /&gt;
# Create simple status script&lt;br /&gt;
cat &amp;gt; /var/www/html/check_status.sh &amp;lt;&amp;lt; &#039;STATUS_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
echo &amp;quot;=== Wiki Status ===&amp;quot;&lt;br /&gt;
echo &amp;quot;Pages: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;Users: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM user WHERE user_id &amp;gt; 0;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Extensions ===&amp;quot;&lt;br /&gt;
if [ -d &amp;quot;extensions/PageNotice&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;PageNotice: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;PageNotice: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/YouTube&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;YouTube: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;YouTube: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/ContributionScores&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;ContributionScores: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;ContributionScores: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Update System ===&amp;quot;&lt;br /&gt;
if [ -f &amp;quot;.last_import&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Current version: $(grep &#039;Import:&#039; .last_import | cut -d&#039; &#039; -f2)&amp;quot;&lt;br /&gt;
    echo &amp;quot;Import date: $(grep &#039;Date:&#039; .last_import | cut -d&#039; &#039; -f2-)&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;No version info available&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;To check for updates: docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;quot;&lt;br /&gt;
STATUS_EOF&lt;br /&gt;
chmod +x /var/www/html/check_status.sh&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Setup completed!&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;Update scripts installed:&amp;quot;&lt;br /&gt;
echo &amp;quot;- /var/www/html/check_updates.sh (user-friendly)&amp;quot;&lt;br /&gt;
echo &amp;quot;- /var/www/html/update_xml.sh (direct)&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Final counts&lt;br /&gt;
PAGES=$(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;0&amp;quot;)&lt;br /&gt;
echo &amp;quot;Pages imported: $PAGES&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb stop&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.4: XML Update Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano update_xml.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;=== CompleteNoobs Wiki XML Updater ===&amp;quot;&lt;br /&gt;
echo &amp;quot;This will check for new and updated pages&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Function to check if running in container&lt;br /&gt;
check_environment() {&lt;br /&gt;
    if [ ! -f &amp;quot;/var/www/html/LocalSettings.php&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;Error: This script must be run inside the wiki container&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to get current XML version&lt;br /&gt;
get_current_version() {&lt;br /&gt;
    if [ -f &amp;quot;/var/www/html/.last_import&amp;quot; ]; then&lt;br /&gt;
        grep &amp;quot;Import:&amp;quot; /var/www/html/.last_import | cut -d&#039; &#039; -f2&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;none&amp;quot;&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to check for updates&lt;br /&gt;
check_for_updates() {&lt;br /&gt;
    python3 - &amp;lt;&amp;lt; &#039;PYTHON_EOF&#039;&lt;br /&gt;
import requests&lt;br /&gt;
from bs4 import BeautifulSoup&lt;br /&gt;
import re&lt;br /&gt;
import sys&lt;br /&gt;
&lt;br /&gt;
BASE_URL = &amp;quot;https://xml.completenoobs.com/xmlDumps/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
def parse_date_from_dump(dump_name):&lt;br /&gt;
    match = re.match(r&#039;(\d{2})_(\d{2})_(\d{2})\.Noobs&#039;, dump_name)&lt;br /&gt;
    if match:&lt;br /&gt;
        day, month, year = match.groups()&lt;br /&gt;
        year_int = int(year)&lt;br /&gt;
        full_year = 2000 + year_int if year_int &amp;lt;= 49 else 1900 + year_int&lt;br /&gt;
        return (full_year, int(month), int(day))&lt;br /&gt;
    return (0, 0, 0)&lt;br /&gt;
&lt;br /&gt;
def get_latest_dump():&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(BASE_URL, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        dumps = [link.get(&#039;href&#039;).rstrip(&#039;/&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if re.match(r&#039;\d{2}_\d{2}_\d{2}\.Noobs/$&#039;, link.get(&#039;href&#039;, &#039;&#039;))]&lt;br /&gt;
        if dumps:&lt;br /&gt;
            latest = sorted(dumps, key=parse_date_from_dump, reverse=True)[0]&lt;br /&gt;
            &lt;br /&gt;
            # Get XML files from latest dump&lt;br /&gt;
            response = requests.get(f&amp;quot;{BASE_URL}{latest}/&amp;quot;, timeout=30)&lt;br /&gt;
            response.raise_for_status()&lt;br /&gt;
            soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
            files = [link.get(&#039;href&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                    if link.get(&#039;href&#039;, &#039;&#039;).endswith(&#039;.xml&#039;)]&lt;br /&gt;
            &lt;br /&gt;
            if files:&lt;br /&gt;
                newest_xml = sorted(files, reverse=True)[0]&lt;br /&gt;
                print(f&amp;quot;{latest}/{newest_xml}&amp;quot;)&lt;br /&gt;
                sys.exit(0)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;ERROR: {e}&amp;quot;, file=sys.stderr)&lt;br /&gt;
        sys.exit(1)&lt;br /&gt;
    &lt;br /&gt;
    print(&amp;quot;ERROR: No dumps found&amp;quot;, file=sys.stderr)&lt;br /&gt;
    sys.exit(1)&lt;br /&gt;
&lt;br /&gt;
get_latest_dump()&lt;br /&gt;
PYTHON_EOF&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to download new XML&lt;br /&gt;
download_xml() {&lt;br /&gt;
    local dump_info=&amp;quot;$1&amp;quot;&lt;br /&gt;
    local dump_dir=$(echo &amp;quot;$dump_info&amp;quot; | cut -d&#039;/&#039; -f1)&lt;br /&gt;
    local xml_file=$(echo &amp;quot;$dump_info&amp;quot; | cut -d&#039;/&#039; -f2)&lt;br /&gt;
    local url=&amp;quot;https://xml.completenoobs.com/xmlDumps/${dump_info}&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Downloading: $xml_file&amp;quot;&lt;br /&gt;
    echo &amp;quot;From: $dump_dir&amp;quot;&lt;br /&gt;
    echo &amp;quot;URL: $url&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if wget -O /tmp/new_dump.xml &amp;quot;$url&amp;quot; --progress=bar:force 2&amp;gt;&amp;amp;1; then&lt;br /&gt;
        echo &amp;quot;Download successful!&amp;quot;&lt;br /&gt;
        return 0&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;Download failed!&amp;quot;&lt;br /&gt;
        return 1&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to backup current database&lt;br /&gt;
backup_database() {&lt;br /&gt;
    echo &amp;quot;Creating database backup...&amp;quot;&lt;br /&gt;
    TIMESTAMP=$(date +%Y%m%d_%H%M%S)&lt;br /&gt;
    mysqldump --user=wikiuser --password=wikipass completenoobs_wiki &amp;gt; /tmp/wiki_backup_${TIMESTAMP}.sql&lt;br /&gt;
    echo &amp;quot;Backup created: /tmp/wiki_backup_${TIMESTAMP}.sql&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to analyze and import changes&lt;br /&gt;
analyze_and_import() {&lt;br /&gt;
    echo &amp;quot;Analyzing differences between XML and local wiki...&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Create analysis and import script for MediaWiki 1.44+&lt;br /&gt;
    cat &amp;gt; /tmp/analyze_import.php &amp;lt;&amp;lt; &#039;PHP_EOF&#039;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
require_once &#039;/var/www/html/maintenance/Maintenance.php&#039;;&lt;br /&gt;
&lt;br /&gt;
class AnalyzeAndImport extends Maintenance {&lt;br /&gt;
    private $db;&lt;br /&gt;
    private $new_pages = [];&lt;br /&gt;
    private $changed_pages = [];&lt;br /&gt;
    private $unchanged_pages = [];&lt;br /&gt;
    &lt;br /&gt;
    public function __construct() {&lt;br /&gt;
        parent::__construct();&lt;br /&gt;
        $this-&amp;gt;addDescription(&#039;Analyze and selectively import from XML dump&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    public function execute() {&lt;br /&gt;
        $this-&amp;gt;db = new mysqli(&#039;127.0.0.1&#039;, &#039;wikiuser&#039;, &#039;wikipass&#039;, &#039;completenoobs_wiki&#039;);&lt;br /&gt;
        &lt;br /&gt;
        // MediaWiki 1.35+ uses slots and content tables&lt;br /&gt;
        // Get existing pages with their content&lt;br /&gt;
        $query = &amp;quot;&lt;br /&gt;
            SELECT p.page_title, p.page_id, c.content_address, c.content_sha1&lt;br /&gt;
            FROM page p&lt;br /&gt;
            JOIN revision r ON p.page_latest = r.rev_id&lt;br /&gt;
            JOIN slots s ON r.rev_id = s.slot_revision_id&lt;br /&gt;
            JOIN slot_roles sr ON s.slot_role_id = sr.role_id&lt;br /&gt;
            JOIN content c ON s.slot_content_id = c.content_id&lt;br /&gt;
            WHERE p.page_namespace = 0 AND sr.role_name = &#039;main&#039;&lt;br /&gt;
        &amp;quot;;&lt;br /&gt;
        &lt;br /&gt;
        $result = $this-&amp;gt;db-&amp;gt;query($query);&lt;br /&gt;
        if (!$result) {&lt;br /&gt;
            $this-&amp;gt;error(&amp;quot;Database query failed: &amp;quot; . $this-&amp;gt;db-&amp;gt;error);&lt;br /&gt;
            return;&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $existing = [];&lt;br /&gt;
        while ($row = $result-&amp;gt;fetch_assoc()) {&lt;br /&gt;
            // Get actual text content&lt;br /&gt;
            $text_content = $this-&amp;gt;getTextContent($row[&#039;content_address&#039;]);&lt;br /&gt;
            $existing[$row[&#039;page_title&#039;]] = [&lt;br /&gt;
                &#039;id&#039; =&amp;gt; $row[&#039;page_id&#039;],&lt;br /&gt;
                &#039;content&#039; =&amp;gt; $text_content,&lt;br /&gt;
                &#039;sha1&#039; =&amp;gt; $row[&#039;content_sha1&#039;]&lt;br /&gt;
            ];&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Parse XML and compare&lt;br /&gt;
        $xml = simplexml_load_file(&#039;/tmp/new_dump.xml&#039;);&lt;br /&gt;
        &lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            $title = str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title);&lt;br /&gt;
            $xml_content = (string)$page-&amp;gt;revision-&amp;gt;text;&lt;br /&gt;
            &lt;br /&gt;
            if (!isset($existing[$title])) {&lt;br /&gt;
                // New page&lt;br /&gt;
                $this-&amp;gt;new_pages[$title] = $xml_content;&lt;br /&gt;
            } else {&lt;br /&gt;
                // Compare content using SHA1 for efficiency&lt;br /&gt;
                $xml_sha1 = sha1($xml_content);&lt;br /&gt;
                &lt;br /&gt;
                if ($existing[$title][&#039;sha1&#039;] !== $xml_sha1) {&lt;br /&gt;
                    // Content is different&lt;br /&gt;
                    $this-&amp;gt;changed_pages[$title] = [&lt;br /&gt;
                        &#039;local&#039; =&amp;gt; $existing[$title][&#039;content&#039;],&lt;br /&gt;
                        &#039;xml&#039; =&amp;gt; $xml_content,&lt;br /&gt;
                        &#039;page_id&#039; =&amp;gt; $existing[$title][&#039;id&#039;]&lt;br /&gt;
                    ];&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;unchanged_pages[] = $title;&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Display summary&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;\n=== Update Analysis ===\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;New pages to import: &amp;quot; . count($this-&amp;gt;new_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Changed pages found: &amp;quot; . count($this-&amp;gt;changed_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Unchanged pages: &amp;quot; . count($this-&amp;gt;unchanged_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        &lt;br /&gt;
        // Save analysis for review&lt;br /&gt;
        file_put_contents(&#039;/tmp/update_analysis.json&#039;, json_encode([&lt;br /&gt;
            &#039;new&#039; =&amp;gt; array_keys($this-&amp;gt;new_pages),&lt;br /&gt;
            &#039;changed&#039; =&amp;gt; array_keys($this-&amp;gt;changed_pages),&lt;br /&gt;
            &#039;unchanged&#039; =&amp;gt; $this-&amp;gt;unchanged_pages&lt;br /&gt;
        ], JSON_PRETTY_PRINT));&lt;br /&gt;
        &lt;br /&gt;
        // Show changed pages with preview (limit to first 20 for readability)&lt;br /&gt;
        if (count($this-&amp;gt;changed_pages) &amp;gt; 0) {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Changed Pages ===\n&amp;quot;);&lt;br /&gt;
            $count = 0;&lt;br /&gt;
            $total_changed = count($this-&amp;gt;changed_pages);&lt;br /&gt;
            &lt;br /&gt;
            foreach ($this-&amp;gt;changed_pages as $title =&amp;gt; $data) {&lt;br /&gt;
                $count++;&lt;br /&gt;
                if ($count &amp;lt;= 20) {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;\n$count. $title\n&amp;quot;);&lt;br /&gt;
                    &lt;br /&gt;
                    // Create a simple diff preview (first 300 chars)&lt;br /&gt;
                    $xml_preview = substr($data[&#039;xml&#039;], 0, 100);&lt;br /&gt;
                    &lt;br /&gt;
                    // Save full diff to file&lt;br /&gt;
                    $safe_title = preg_replace(&#039;/[^a-zA-Z0-9_-]/&#039;, &#039;_&#039;, $title);&lt;br /&gt;
                    $diff_file = &amp;quot;/tmp/diff_${safe_title}.txt&amp;quot;;&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;=== FULL DIFF FOR: $title ===\n\n&amp;quot;);&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;--- LOCAL VERSION ---\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, $data[&#039;local&#039;] . &amp;quot;\n\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;--- XML VERSION ---\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, $data[&#039;xml&#039;], FILE_APPEND);&lt;br /&gt;
                    &lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;   Preview: &amp;quot; . $xml_preview . &amp;quot;...\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
            &lt;br /&gt;
            if ($total_changed &amp;gt; 20) {&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;\n... and &amp;quot; . ($total_changed - 20) . &amp;quot; more changed pages.\n&amp;quot;);&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;All diff files saved to /tmp/diff_*.txt\n&amp;quot;);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Interactive selection&lt;br /&gt;
        if (count($this-&amp;gt;new_pages) &amp;gt; 0 || count($this-&amp;gt;changed_pages) &amp;gt; 0) {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Import Options ===\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;1. Import new pages only (preserve all local changes)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;2. Import new pages + update ALL changed pages (overwrites local changes)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;3. Selective import (choose which updates to apply)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;4. Cancel (no changes)\n&amp;quot;);&lt;br /&gt;
            &lt;br /&gt;
            // Save state for import script WITHOUT the XML object&lt;br /&gt;
            $import_data = [&lt;br /&gt;
                &#039;new_pages&#039; =&amp;gt; $this-&amp;gt;new_pages,&lt;br /&gt;
                &#039;changed_pages&#039; =&amp;gt; $this-&amp;gt;changed_pages,&lt;br /&gt;
                &#039;xml_file&#039; =&amp;gt; &#039;/tmp/new_dump.xml&#039;  // Save path instead of object&lt;br /&gt;
            ];&lt;br /&gt;
            &lt;br /&gt;
            file_put_contents(&#039;/tmp/import_data.ser&#039;, serialize($import_data));&lt;br /&gt;
        } else {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\nNo changes detected. Your wiki is up to date!\n&amp;quot;);&lt;br /&gt;
        }&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function getTextContent($address) {&lt;br /&gt;
        // Handle different content storage formats in MW 1.35+&lt;br /&gt;
        if (strpos($address, &#039;tt:&#039;) === 0) {&lt;br /&gt;
            // Text table reference&lt;br /&gt;
            $text_id = substr($address, 3);&lt;br /&gt;
            $result = $this-&amp;gt;db-&amp;gt;query(&amp;quot;SELECT old_text FROM text WHERE old_id = $text_id&amp;quot;);&lt;br /&gt;
            if ($row = $result-&amp;gt;fetch_assoc()) {&lt;br /&gt;
                return $row[&#039;old_text&#039;];&lt;br /&gt;
            }&lt;br /&gt;
        } elseif (strpos($address, &#039;es:&#039;) === 0) {&lt;br /&gt;
            // External storage - would need special handling&lt;br /&gt;
            return &amp;quot;[External storage content]&amp;quot;;&lt;br /&gt;
        }&lt;br /&gt;
        // Direct content&lt;br /&gt;
        return $address;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
$maintClass = AnalyzeAndImport::class;&lt;br /&gt;
require_once RUN_MAINTENANCE_IF_MAIN;&lt;br /&gt;
PHP_EOF&lt;br /&gt;
&lt;br /&gt;
    cd /var/www/html&lt;br /&gt;
    php /tmp/analyze_import.php&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to perform selected import&lt;br /&gt;
perform_import() {&lt;br /&gt;
    local choice=$1&lt;br /&gt;
    &lt;br /&gt;
    cat &amp;gt; /tmp/do_import.php &amp;lt;&amp;lt; &#039;PHP_EOF&#039;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
require_once &#039;/var/www/html/maintenance/Maintenance.php&#039;;&lt;br /&gt;
&lt;br /&gt;
class DoImport extends Maintenance {&lt;br /&gt;
    public function __construct() {&lt;br /&gt;
        parent::__construct();&lt;br /&gt;
        $this-&amp;gt;addOption(&#039;mode&#039;, &#039;Import mode&#039;, true, true);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    public function execute() {&lt;br /&gt;
        $mode = $this-&amp;gt;getOption(&#039;mode&#039;);&lt;br /&gt;
        $data = unserialize(file_get_contents(&#039;/tmp/import_data.ser&#039;));&lt;br /&gt;
        &lt;br /&gt;
        // Load XML file&lt;br /&gt;
        $xml = simplexml_load_file($data[&#039;xml_file&#039;]);&lt;br /&gt;
        &lt;br /&gt;
        $new_imported = 0;&lt;br /&gt;
        $updated = 0;&lt;br /&gt;
        &lt;br /&gt;
        // Import new pages (always for modes 1-3)&lt;br /&gt;
        if ($mode != &#039;4&#039;) {&lt;br /&gt;
            foreach ($data[&#039;new_pages&#039;] as $title =&amp;gt; $content) {&lt;br /&gt;
                $this-&amp;gt;importPage($title, $content, $xml);&lt;br /&gt;
                $new_imported++;&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Imported new page: $title\n&amp;quot;);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Handle changed pages based on mode&lt;br /&gt;
        if ($mode == &#039;2&#039;) {&lt;br /&gt;
            // Update all changed pages&lt;br /&gt;
            foreach ($data[&#039;changed_pages&#039;] as $title =&amp;gt; $info) {&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Updating: $title\n&amp;quot;);&lt;br /&gt;
                if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                    $updated++;&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Updated page: $title\n&amp;quot;);&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Failed to update: $title\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        } elseif ($mode == &#039;3&#039;) {&lt;br /&gt;
            // Selective update&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Selective Import Mode ===\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;For each changed page, choose:\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  y = yes, update this page\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  n = no, keep local version\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  d = show diff file\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  a = update all remaining pages\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  s = skip all remaining pages\n\n&amp;quot;);&lt;br /&gt;
            &lt;br /&gt;
            $update_all = false;&lt;br /&gt;
            $skip_all = false;&lt;br /&gt;
            &lt;br /&gt;
            foreach ($data[&#039;changed_pages&#039;] as $title =&amp;gt; $info) {&lt;br /&gt;
                if ($skip_all) {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                    continue;&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                if ($update_all) {&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                    continue;&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;\nPage: $title\n&amp;quot;);&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Action (y/n/d/a/s): &amp;quot;);&lt;br /&gt;
                $handle = fopen(&amp;quot;php://stdin&amp;quot;, &amp;quot;r&amp;quot;);&lt;br /&gt;
                $line = trim(fgets($handle));&lt;br /&gt;
                &lt;br /&gt;
                while ($line == &#039;d&#039;) {&lt;br /&gt;
                    // Show diff&lt;br /&gt;
                    $safe_title = preg_replace(&#039;/[^a-zA-Z0-9_-]/&#039;, &#039;_&#039;, $title);&lt;br /&gt;
                    $diff_file = &amp;quot;/tmp/diff_${safe_title}.txt&amp;quot;;&lt;br /&gt;
                    if (file_exists($diff_file)) {&lt;br /&gt;
                        system(&amp;quot;head -50 $diff_file&amp;quot;);&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;\n[Showing first 50 lines - full file at $diff_file]\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Action (y/n/a/s): &amp;quot;);&lt;br /&gt;
                    $line = trim(fgets($handle));&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                if ($line == &#039;a&#039;) {&lt;br /&gt;
                    $update_all = true;&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                } elseif ($line == &#039;s&#039;) {&lt;br /&gt;
                    $skip_all = true;&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                } elseif ($line == &#039;y&#039;) {&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Failed to update: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;\n=== Import Complete ===\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;New pages imported: $new_imported\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Pages updated: $updated\n&amp;quot;);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function importPage($title, $content, $xml) {&lt;br /&gt;
        // Create single page XML for import&lt;br /&gt;
        $tempFile = &#039;/tmp/single_page_&#039; . md5($title) . &#039;.xml&#039;;&lt;br /&gt;
        $singlePage = new SimpleXMLElement(&#039;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;&amp;lt;mediawiki&amp;gt;&amp;lt;/mediawiki&amp;gt;&#039;);&lt;br /&gt;
        &lt;br /&gt;
        if ($xml-&amp;gt;siteinfo) {&lt;br /&gt;
            $siteinfo = $singlePage-&amp;gt;addChild(&#039;siteinfo&#039;);&lt;br /&gt;
            foreach ($xml-&amp;gt;siteinfo-&amp;gt;children() as $child) {&lt;br /&gt;
                $siteinfo-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Find and add the page&lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            if (str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title) == $title) {&lt;br /&gt;
                $newPage = $singlePage-&amp;gt;addChild(&#039;page&#039;);&lt;br /&gt;
                foreach ($page-&amp;gt;children() as $child) {&lt;br /&gt;
                    if ($child-&amp;gt;getName() == &#039;revision&#039;) {&lt;br /&gt;
                        $revision = $newPage-&amp;gt;addChild(&#039;revision&#039;);&lt;br /&gt;
                        foreach ($child-&amp;gt;children() as $revChild) {&lt;br /&gt;
                            $revision-&amp;gt;addChild($revChild-&amp;gt;getName(), (string)$revChild);&lt;br /&gt;
                        }&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $newPage-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                break;&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $singlePage-&amp;gt;asXML($tempFile);&lt;br /&gt;
        exec(&amp;quot;php /var/www/html/maintenance/importDump.php &amp;lt; $tempFile 2&amp;gt;&amp;amp;1&amp;quot;);&lt;br /&gt;
        unlink($tempFile);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function reimportPage($title, $xml) {&lt;br /&gt;
        // For updating existing pages, delete then reimport&lt;br /&gt;
        $db = new mysqli(&#039;127.0.0.1&#039;, &#039;wikiuser&#039;, &#039;wikipass&#039;, &#039;completenoobs_wiki&#039;);&lt;br /&gt;
        &lt;br /&gt;
        // Delete the existing page&lt;br /&gt;
        $safe_title = $db-&amp;gt;real_escape_string(str_replace(&#039; &#039;, &#039;_&#039;, $title));&lt;br /&gt;
        $db-&amp;gt;query(&amp;quot;DELETE FROM page WHERE page_title = &#039;$safe_title&#039; AND page_namespace = 0&amp;quot;);&lt;br /&gt;
        &lt;br /&gt;
        $db-&amp;gt;close();&lt;br /&gt;
        &lt;br /&gt;
        // Now import the new version&lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            if (str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title) == $title) {&lt;br /&gt;
                $tempFile = &#039;/tmp/update_page_&#039; . md5($title) . &#039;.xml&#039;;&lt;br /&gt;
                $singlePage = new SimpleXMLElement(&#039;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;&amp;lt;mediawiki&amp;gt;&amp;lt;/mediawiki&amp;gt;&#039;);&lt;br /&gt;
                &lt;br /&gt;
                if ($xml-&amp;gt;siteinfo) {&lt;br /&gt;
                    $siteinfo = $singlePage-&amp;gt;addChild(&#039;siteinfo&#039;);&lt;br /&gt;
                    foreach ($xml-&amp;gt;siteinfo-&amp;gt;children() as $child) {&lt;br /&gt;
                        $siteinfo-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $newPage = $singlePage-&amp;gt;addChild(&#039;page&#039;);&lt;br /&gt;
                foreach ($page-&amp;gt;children() as $child) {&lt;br /&gt;
                    if ($child-&amp;gt;getName() == &#039;revision&#039;) {&lt;br /&gt;
                        $revision = $newPage-&amp;gt;addChild(&#039;revision&#039;);&lt;br /&gt;
                        foreach ($child-&amp;gt;children() as $revChild) {&lt;br /&gt;
                            $revision-&amp;gt;addChild($revChild-&amp;gt;getName(), (string)$revChild);&lt;br /&gt;
                        }&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $newPage-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $singlePage-&amp;gt;asXML($tempFile);&lt;br /&gt;
                $result = exec(&amp;quot;php /var/www/html/maintenance/importDump.php &amp;lt; $tempFile 2&amp;gt;&amp;amp;1&amp;quot;, $output, $return);&lt;br /&gt;
                unlink($tempFile);&lt;br /&gt;
                &lt;br /&gt;
                return ($return === 0);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        return false;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
$maintClass = DoImport::class;&lt;br /&gt;
require_once RUN_MAINTENANCE_IF_MAIN;&lt;br /&gt;
PHP_EOF&lt;br /&gt;
&lt;br /&gt;
    cd /var/www/html&lt;br /&gt;
    php /tmp/do_import.php --mode=&amp;quot;$choice&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Main execution&lt;br /&gt;
main() {&lt;br /&gt;
    check_environment&lt;br /&gt;
    &lt;br /&gt;
    # Start MariaDB if not running&lt;br /&gt;
    service mariadb status &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 || service mariadb start&lt;br /&gt;
    &lt;br /&gt;
    # Wait for MariaDB&lt;br /&gt;
    for i in {1..30}; do&lt;br /&gt;
        if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
            break&lt;br /&gt;
        fi&lt;br /&gt;
        sleep 1&lt;br /&gt;
    done&lt;br /&gt;
    &lt;br /&gt;
    CURRENT=$(get_current_version)&lt;br /&gt;
    echo &amp;quot;Current version: $CURRENT&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Checking for updates...&amp;quot;&lt;br /&gt;
    LATEST=$(check_for_updates 2&amp;gt;/dev/null)&lt;br /&gt;
    if [ $? -ne 0 ] || [ -z &amp;quot;$LATEST&amp;quot; ] || [[ &amp;quot;$LATEST&amp;quot; == *&amp;quot;ERROR&amp;quot;* ]]; then&lt;br /&gt;
        echo &amp;quot;Failed to check for updates&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Latest available: $LATEST&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Always proceed to analysis even if versions match&lt;br /&gt;
    # (there might be content updates in the same version)&lt;br /&gt;
    echo &amp;quot;Proceeding with content analysis...&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Backup database&lt;br /&gt;
    backup_database&lt;br /&gt;
    &lt;br /&gt;
    # Download new XML&lt;br /&gt;
    if ! download_xml &amp;quot;$LATEST&amp;quot;; then&lt;br /&gt;
        echo &amp;quot;Failed to download new XML&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Analyze differences&lt;br /&gt;
    analyze_and_import&lt;br /&gt;
    &lt;br /&gt;
    # Check if there are changes to import&lt;br /&gt;
    if [ -f &amp;quot;/tmp/import_data.ser&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;&amp;quot;&lt;br /&gt;
        read -p &amp;quot;Choose option (1-4): &amp;quot; -n 1 -r&lt;br /&gt;
        echo&lt;br /&gt;
        &lt;br /&gt;
        if [[ $REPLY =~ ^[1-4]$ ]]; then&lt;br /&gt;
            if [ &amp;quot;$REPLY&amp;quot; != &amp;quot;4&amp;quot; ]; then&lt;br /&gt;
                perform_import &amp;quot;$REPLY&amp;quot;&lt;br /&gt;
                &lt;br /&gt;
                # Update version info&lt;br /&gt;
                echo &amp;quot;Import: $LATEST&amp;quot; &amp;gt; /var/www/html/.last_import&lt;br /&gt;
                echo &amp;quot;Date: $(date)&amp;quot; &amp;gt;&amp;gt; /var/www/html/.last_import&lt;br /&gt;
                &lt;br /&gt;
                # Rebuild indices&lt;br /&gt;
                echo &amp;quot;Rebuilding indices...&amp;quot;&lt;br /&gt;
                php maintenance/rebuildrecentchanges.php&lt;br /&gt;
                php maintenance/initSiteStats.php&lt;br /&gt;
            else&lt;br /&gt;
                echo &amp;quot;Update cancelled&amp;quot;&lt;br /&gt;
            fi&lt;br /&gt;
        else&lt;br /&gt;
            echo &amp;quot;Invalid option. Update cancelled&amp;quot;&lt;br /&gt;
        fi&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Clean up temp files&lt;br /&gt;
    rm -f /tmp/import_data.ser /tmp/update_analysis.json /tmp/diff_*.txt /tmp/analyze_import.php /tmp/do_import.php 2&amp;gt;/dev/null&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    echo &amp;quot;Done!&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Run main function&lt;br /&gt;
main &amp;quot;$@&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.5: Entrypoint Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano entrypoint.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Starting CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 1&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;CompleteNoobs Wiki ready at: http://localhost:8080&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin login: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Features:&amp;quot;&lt;br /&gt;
echo &amp;quot;- Complete wiki content imported from XML&amp;quot;&lt;br /&gt;
echo &amp;quot;- License notices on all pages (via PageNotice)&amp;quot;&lt;br /&gt;
echo &amp;quot;- SyntaxHighlight for code blocks&amp;quot;&lt;br /&gt;
echo &amp;quot;- YouTube video embedding&amp;quot;&lt;br /&gt;
echo &amp;quot;- Contribution Scores special page&amp;quot;&lt;br /&gt;
echo &amp;quot;- XML update system (preserves local edits)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;To check for updates: docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
apache2-foreground&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 3: Build and Run ==&lt;br /&gt;
&lt;br /&gt;
=== 3.1: Build the Image ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker build -t completenoobs/wiki:latest .&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This will take several minutes.&lt;br /&gt;
&lt;br /&gt;
=== 3.2: Run the Container ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki completenoobs/wiki:latest&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* NOTE: The above &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; command is for quick testing, if you want to be able to export your wiki&#039;s database to an XML file you can backup and share, please use method in expanding info box below. &lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
If you want to be able to Export Your MediaWiki Database to Dated XML File - use this &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; method:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To export your MediaWiki database to a dated XML file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) and save it to the host’s &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory, run the export script inside the Docker container and use a volume mount to write the file to the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Create Host Directory&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, ensure the &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory exists:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Run Container with Volume Mount&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If your container isn’t already using a volume for &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt;, stop and remove it, then restart with a volume mount:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Run Export Script in Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Access the container’s shell:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Inside the container, run the export script to create a dated XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
DATE=$(date +%Y%m%d)&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:/export/$DATE.xml&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This writes the file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt; in the container, which maps to &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; on the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify the File&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, check for the XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ls ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
You should see a file like &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Notes&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
- If you encounter permission issues, ensure the container’s user has write access to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt;:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
chmod -R 777 /export&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- The script must be run inside the container, as it requires MediaWiki’s environment and database access.&amp;lt;br&amp;gt;&lt;br /&gt;
- If your container uses a different volume setup, adjust the mount point accordingly.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 4: Test Everything ==&lt;br /&gt;
&lt;br /&gt;
=== 4.1: Check the Wiki ===&lt;br /&gt;
* Visit: http://localhost:8080&lt;br /&gt;
* You should see the PageNotice at the top&lt;br /&gt;
* Login with: admin / AdminPass123!&lt;br /&gt;
{{:Restore_the_completenoobs_Main_Page}}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Change Admin Password:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
By default, the admin user&#039;s password is &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;. It&#039;s highly recommended to change this immediately. You can do this either through the wiki&#039;s web interface or directly in the Docker terminal.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 1: Change Password via Web Interface&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
This is the easiest method. You can change your password directly from the wiki itself.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. Log in to your wiki with the default credentials: &amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
2. Once logged in, click your username (&amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt;) in the top-right corner of the page.&amp;lt;br&amp;gt;&lt;br /&gt;
3. From the drop-down menu, select &amp;lt;b&amp;gt;Preferences&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
4. On the Preferences page, go to the &amp;lt;b&amp;gt;Password&amp;lt;/b&amp;gt; tab.&amp;lt;br&amp;gt;&lt;br /&gt;
5. Enter the current password (&amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;), then enter your new password twice.&amp;lt;br&amp;gt;&lt;br /&gt;
6. Click &amp;lt;b&amp;gt;Change password&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Your password is now changed, and you will need to use the new one for future logins.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 2: Change Password via Terminal (No-Email Reset)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you have forgotten the password or prefer to use the command line, you can reset it directly inside the Docker container using a MediaWiki maintenance script.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. &amp;lt;b&amp;gt;Access the container&#039;s shell&amp;lt;/b&amp;gt; with the following command from your host machine:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
2. Once inside the container, use the &amp;lt;code&amp;gt;changePassword.php&amp;lt;/code&amp;gt; maintenance script to change the password. This is the modern, recommended way to run MediaWiki maintenance scripts.&amp;lt;br&amp;gt;&lt;br /&gt;
* Change &amp;lt;b&amp;gt;NEWPASSWORD&amp;lt;/b&amp;gt; to your new password&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
3. Type &amp;lt;code&amp;gt;exit&amp;lt;/code&amp;gt; to leave the container&#039;s shell.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The admin password has now been reset. You can log in to your wiki with the new password.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.2: Test Extensions ===&lt;br /&gt;
* &#039;&#039;&#039;YouTube&#039;&#039;&#039;: Edit any page, add &amp;lt;code&amp;gt;&amp;lt;youtube&amp;gt;N9qYF9DZPdw&amp;lt;/youtube&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;PageNotice&#039;&#039;&#039;: Should already be visible at the top&lt;br /&gt;
* &#039;&#039;&#039;SyntaxHighlight&#039;&#039;&#039;: Add code blocks with &amp;lt;nowiki&amp;gt;&amp;lt;code&amp;gt;&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;code here&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/nowiki&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.3: Check Status ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/check_status.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 5: XML Update Operations ==&lt;br /&gt;
*NOTE: update_xml.sh still needs alot of work, this just idea placeholder for now.&lt;br /&gt;
=== 5.1: Check for Updates (Interactive) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This will:&lt;br /&gt;
* Check the CompleteNoobs XML repository for new dumps&lt;br /&gt;
* Compare with your current version&lt;br /&gt;
* Ask for confirmation before updating&lt;br /&gt;
* Import ONLY new pages (preserves your edits)&lt;br /&gt;
* Create a backup before making changes&lt;br /&gt;
&lt;br /&gt;
=== 5.2: Force Update (Non-Interactive) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki bash -c &amp;quot;echo &#039;y&#039; | /var/www/html/update_xml.sh&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5.3: Manual Update Process ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Enter container&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&lt;br /&gt;
# 2. Check current version&lt;br /&gt;
cat /var/www/html/.last_import&lt;br /&gt;
&lt;br /&gt;
# 3. Run update&lt;br /&gt;
/var/www/html/check_updates.sh&lt;br /&gt;
&lt;br /&gt;
# 4. Exit container&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 6: Troubleshooting Commands ==&lt;br /&gt;
=== Access container shell: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Edit configuration: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki nano /var/www/html/LocalSettings.php&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Check logs: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== View update logs: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki tail -f /tmp/mediawiki-debug.log&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Complete restart: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki completenoobs/wiki:latest&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Expected Results ==&lt;br /&gt;
* Working wiki with imported CompleteNoobs content&lt;br /&gt;
* PageNotice visible at top of all pages&lt;br /&gt;
* All extensions functional&lt;br /&gt;
* Text editors (nano/vim) available in container&lt;br /&gt;
* Utility scripts for maintenance&lt;br /&gt;
* XML update system that preserves local edits&lt;br /&gt;
&lt;br /&gt;
==need to add==&lt;br /&gt;
* way for user to backup there local custom wiki - xml exporter&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Talk:Restore_the_completenoobs_Main_Page&amp;diff=670</id>
		<title>Talk:Restore the completenoobs Main Page</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Talk:Restore_the_completenoobs_Main_Page&amp;diff=670"/>
		<updated>2025-09-02T10:38:21Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;* Enter in page - saves retyping  &amp;lt;pre&amp;gt;{{:Restore_the_completenoobs_Main_Page}}&amp;lt;/pre&amp;gt;&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;* Enter in page - saves retyping &lt;br /&gt;
&amp;lt;pre&amp;gt;{{:Restore_the_completenoobs_Main_Page}}&amp;lt;/pre&amp;gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Creation&amp;diff=669</id>
		<title>CompleteNoobs Docker Image Creation</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Creation&amp;diff=669"/>
		<updated>2025-09-02T10:23:46Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* 4.1: Check the Wiki */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Complete Noobs Docker Wiki Tutorial =&lt;br /&gt;
* [[Docker_Install_Guide| Docker install guide]]&lt;br /&gt;
==errors==&lt;br /&gt;
* This mainly works - just need to fix the extensions popular pages and contrubtion scores&lt;br /&gt;
* The XML updater requires more work - currently idea placeholder&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
* Ubuntu 24.04&lt;br /&gt;
* Docker installed and running&lt;br /&gt;
* Your user in docker group: &amp;lt;code&amp;gt;sudo usermod -aG docker $USER&amp;lt;/code&amp;gt; (then logout/login)&lt;br /&gt;
&lt;br /&gt;
== Step 2: Create All Files ==&lt;br /&gt;
&lt;br /&gt;
=== 2.1: Dockerfile ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano Dockerfile&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Copy this exactly:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;dockerfile&amp;quot;&amp;gt;&lt;br /&gt;
FROM mediawiki:1.44&lt;br /&gt;
# Mediawiki 1.44 used over latest because can confirm extensions youtube and pagenotice works&lt;br /&gt;
# Install dependencies&lt;br /&gt;
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \&lt;br /&gt;
    mariadb-server \&lt;br /&gt;
    python3 \&lt;br /&gt;
    python3-requests \&lt;br /&gt;
    python3-bs4 \&lt;br /&gt;
    python3-pygments \&lt;br /&gt;
    curl \&lt;br /&gt;
    wget \&lt;br /&gt;
    unzip \&lt;br /&gt;
    nano \&lt;br /&gt;
    git \&lt;br /&gt;
    &amp;amp;&amp;amp; apt-get clean&lt;br /&gt;
&lt;br /&gt;
# Copy scripts&lt;br /&gt;
COPY download_latest_xml.py /usr/src/download_latest_xml.py&lt;br /&gt;
COPY setup_wiki.sh /usr/src/setup_wiki.sh&lt;br /&gt;
COPY update_xml.sh /usr/src/update_xml.sh&lt;br /&gt;
COPY entrypoint.sh /entrypoint.sh&lt;br /&gt;
&lt;br /&gt;
# Make executable&lt;br /&gt;
RUN chmod +x /usr/src/setup_wiki.sh /entrypoint.sh /usr/src/update_xml.sh&lt;br /&gt;
&lt;br /&gt;
# Download XML&lt;br /&gt;
RUN python3 /usr/src/download_latest_xml.py&lt;br /&gt;
&lt;br /&gt;
# Setup wiki&lt;br /&gt;
RUN /usr/src/setup_wiki.sh&lt;br /&gt;
&lt;br /&gt;
EXPOSE 80&lt;br /&gt;
VOLUME /var/lib/mysql&lt;br /&gt;
VOLUME /var/www/html/images&lt;br /&gt;
&lt;br /&gt;
ENTRYPOINT [&amp;quot;/entrypoint.sh&amp;quot;]&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.2: XML Download Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano download_latest_xml.py&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
import os&lt;br /&gt;
import requests&lt;br /&gt;
from bs4 import BeautifulSoup&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
BASE_URL = &amp;quot;https://xml.completenoobs.com/xmlDumps/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
def parse_date_from_dump(dump_name):&lt;br /&gt;
    match = re.match(r&#039;(\d{2})_(\d{2})_(\d{2})\.Noobs&#039;, dump_name)&lt;br /&gt;
    if match:&lt;br /&gt;
        day, month, year = match.groups()&lt;br /&gt;
        year_int = int(year)&lt;br /&gt;
        full_year = 2000 + year_int if year_int &amp;lt;= 49 else 1900 + year_int&lt;br /&gt;
        return (full_year, int(month), int(day))&lt;br /&gt;
    return (0, 0, 0)&lt;br /&gt;
&lt;br /&gt;
def get_available_dumps():&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(BASE_URL, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        dumps = [link.get(&#039;href&#039;).rstrip(&#039;/&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if re.match(r&#039;\d{2}_\d{2}_\d{2}\.Noobs/$&#039;, link.get(&#039;href&#039;, &#039;&#039;))]&lt;br /&gt;
        return sorted(dumps, key=parse_date_from_dump, reverse=True)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error fetching dumps: {e}&amp;quot;)&lt;br /&gt;
        return []&lt;br /&gt;
&lt;br /&gt;
def get_dump_files(dump):&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(f&amp;quot;{BASE_URL}{dump}/&amp;quot;, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        files = [link.get(&#039;href&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if link.get(&#039;href&#039;, &#039;&#039;).endswith(&#039;.xml&#039;)]&lt;br /&gt;
        return sorted(files, reverse=True)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error fetching dump files: {e}&amp;quot;)&lt;br /&gt;
        return []&lt;br /&gt;
&lt;br /&gt;
def download_file(url, filename):&lt;br /&gt;
    try:&lt;br /&gt;
        print(f&amp;quot;Downloading {filename}...&amp;quot;)&lt;br /&gt;
        response = requests.get(url, stream=True, timeout=60)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        &lt;br /&gt;
        total_size = int(response.headers.get(&#039;content-length&#039;, 0))&lt;br /&gt;
        downloaded = 0&lt;br /&gt;
        &lt;br /&gt;
        with open(filename, &#039;wb&#039;) as f:&lt;br /&gt;
            for chunk in response.iter_content(chunk_size=8192):&lt;br /&gt;
                if chunk:&lt;br /&gt;
                    f.write(chunk)&lt;br /&gt;
                    downloaded += len(chunk)&lt;br /&gt;
                    if total_size &amp;gt; 0:&lt;br /&gt;
                        progress = (downloaded / total_size) * 100&lt;br /&gt;
                        print(f&amp;quot;\rProgress: {progress:.1f}%&amp;quot;, end=&#039;&#039;, flush=True)&lt;br /&gt;
        print()&lt;br /&gt;
        return True&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error downloading {filename}: {e}&amp;quot;)&lt;br /&gt;
        return False&lt;br /&gt;
&lt;br /&gt;
def main():&lt;br /&gt;
    print(&amp;quot;Fetching available XML dumps...&amp;quot;)&lt;br /&gt;
    dumps = get_available_dumps()&lt;br /&gt;
    &lt;br /&gt;
    if not dumps:&lt;br /&gt;
        print(&amp;quot;No dumps found!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
    &lt;br /&gt;
    newest_dump = dumps[0]&lt;br /&gt;
    print(f&amp;quot;Latest dump: {newest_dump}&amp;quot;)&lt;br /&gt;
    &lt;br /&gt;
    files = get_dump_files(newest_dump)&lt;br /&gt;
    if not files:&lt;br /&gt;
        print(&amp;quot;No XML files found in latest dump!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
    &lt;br /&gt;
    newest_xml = files[0]&lt;br /&gt;
    xml_url = f&amp;quot;{BASE_URL}{newest_dump}/{newest_xml}&amp;quot;&lt;br /&gt;
    local_filename = &amp;quot;/tmp/completenoobs_dump.xml&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if download_file(xml_url, local_filename):&lt;br /&gt;
        print(f&amp;quot;Successfully downloaded {newest_xml}&amp;quot;)&lt;br /&gt;
        with open(&amp;quot;/tmp/dump_info.txt&amp;quot;, &amp;quot;w&amp;quot;) as f:&lt;br /&gt;
            f.write(f&amp;quot;{newest_dump}/{newest_xml}&amp;quot;)&lt;br /&gt;
    else:&lt;br /&gt;
        print(&amp;quot;Failed to download XML dump!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
&lt;br /&gt;
if __name__ == &amp;quot;__main__&amp;quot;:&lt;br /&gt;
    main()&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.3: Main Setup Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano setup_wiki.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Setting up CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Initialize MariaDB&lt;br /&gt;
if [ ! -d &amp;quot;/var/lib/mysql/mysql&amp;quot; ]; then&lt;br /&gt;
    mysql_install_db --user=mysql --datadir=/var/lib/mysql&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 2&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
# Setup database&lt;br /&gt;
mysql -e &amp;quot;CREATE DATABASE IF NOT EXISTS completenoobs_wiki CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;127.0.0.1&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;127.0.0.1&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;localhost&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;localhost&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;FLUSH PRIVILEGES;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Install MediaWiki&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
php maintenance/install.php \&lt;br /&gt;
    --dbtype=mysql \&lt;br /&gt;
    --dbserver=127.0.0.1 \&lt;br /&gt;
    --dbname=completenoobs_wiki \&lt;br /&gt;
    --dbuser=wikiuser \&lt;br /&gt;
    --dbpass=wikipass \&lt;br /&gt;
    --server=&amp;quot;http://localhost:8080&amp;quot; \&lt;br /&gt;
    --scriptpath=&amp;quot;&amp;quot; \&lt;br /&gt;
    --lang=en \&lt;br /&gt;
    --pass=AdminPass123! \&lt;br /&gt;
    &amp;quot;CompleteNoobs Wiki&amp;quot; \&lt;br /&gt;
    &amp;quot;admin&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Download and install extensions&lt;br /&gt;
cd extensions/&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/PageNotice --branch REL1_44 || echo &amp;quot;PageNotice download failed, continuing...&amp;quot;&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/YouTube --branch REL1_44 || echo &amp;quot;YouTube download failed, continuing...&amp;quot;&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
&lt;br /&gt;
# Configure LocalSettings.php&lt;br /&gt;
cat &amp;gt;&amp;gt; LocalSettings.php &amp;lt;&amp;lt; &#039;EOF&#039;&lt;br /&gt;
# Basic settings&lt;br /&gt;
$wgEnableUploads = true;&lt;br /&gt;
$wgUseImageMagick = true;&lt;br /&gt;
$wgImageMagickConvertCommand = &amp;quot;/usr/bin/convert&amp;quot;;&lt;br /&gt;
$wgDefaultSkin = &amp;quot;vector-2022&amp;quot;;&lt;br /&gt;
$wgAllowExternalImages = true;&lt;br /&gt;
&lt;br /&gt;
# Debug (can be removed later)&lt;br /&gt;
$wgShowExceptionDetails = true;&lt;br /&gt;
$wgDebugLogFile = &amp;quot;/tmp/mediawiki-debug.log&amp;quot;;&lt;br /&gt;
&lt;br /&gt;
# PageNotice extension (if available)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/PageNotice/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;PageNotice&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# YouTube extension (if available)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/YouTube/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;YouTube&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# SyntaxHighlight (usually bundled)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/SyntaxHighlight_GeSHi/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;SyntaxHighlight_GeSHi&#039; );&lt;br /&gt;
    $wgPygmentizePath = &#039;/usr/bin/pygmentize&#039;;&lt;br /&gt;
}&lt;br /&gt;
EOF&lt;br /&gt;
&lt;br /&gt;
# Import XML dump if available&lt;br /&gt;
if [ -f &amp;quot;/tmp/completenoobs_dump.xml&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Importing XML dump...&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if php maintenance/importDump.php --uploads &amp;lt; /tmp/completenoobs_dump.xml; then&lt;br /&gt;
        echo &amp;quot;XML import completed!&amp;quot;&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;XML import had warnings&amp;quot;&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Basic maintenance&lt;br /&gt;
    php maintenance/update.php --quick || echo &amp;quot;Update completed with warnings&amp;quot;&lt;br /&gt;
    php maintenance/rebuildrecentchanges.php || echo &amp;quot;RecentChanges rebuilt with warnings&amp;quot;&lt;br /&gt;
    php maintenance/initSiteStats.php || echo &amp;quot;SiteStats initialized with warnings&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if [ -f &amp;quot;/tmp/dump_info.txt&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;Import: $(cat /tmp/dump_info.txt)&amp;quot; &amp;gt; /var/www/html/.last_import&lt;br /&gt;
        echo &amp;quot;Date: $(date)&amp;quot; &amp;gt;&amp;gt; /var/www/html/.last_import&lt;br /&gt;
    fi&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;No XML dump found - starting with empty wiki&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
# Copy update script to accessible location&lt;br /&gt;
cp /usr/src/update_xml.sh /var/www/html/update_xml.sh&lt;br /&gt;
chmod +x /var/www/html/update_xml.sh&lt;br /&gt;
&lt;br /&gt;
# Create user-friendly update wrapper&lt;br /&gt;
cat &amp;gt; /var/www/html/check_updates.sh &amp;lt;&amp;lt; &#039;UPDATE_WRAPPER_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
echo &amp;quot;=== CompleteNoobs Wiki Update Checker ===&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;This tool checks for new content from the CompleteNoobs XML repository&amp;quot;&lt;br /&gt;
echo &amp;quot;and imports ONLY new pages, preserving all your local edits.&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
/var/www/html/update_xml.sh&lt;br /&gt;
UPDATE_WRAPPER_EOF&lt;br /&gt;
chmod +x /var/www/html/check_updates.sh&lt;br /&gt;
&lt;br /&gt;
# Create simple status script&lt;br /&gt;
cat &amp;gt; /var/www/html/check_status.sh &amp;lt;&amp;lt; &#039;STATUS_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
echo &amp;quot;=== Wiki Status ===&amp;quot;&lt;br /&gt;
echo &amp;quot;Pages: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;Users: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM user WHERE user_id &amp;gt; 0;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Extensions ===&amp;quot;&lt;br /&gt;
if [ -d &amp;quot;extensions/PageNotice&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;PageNotice: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;PageNotice: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/YouTube&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;YouTube: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;YouTube: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/ContributionScores&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;ContributionScores: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;ContributionScores: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Update System ===&amp;quot;&lt;br /&gt;
if [ -f &amp;quot;.last_import&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Current version: $(grep &#039;Import:&#039; .last_import | cut -d&#039; &#039; -f2)&amp;quot;&lt;br /&gt;
    echo &amp;quot;Import date: $(grep &#039;Date:&#039; .last_import | cut -d&#039; &#039; -f2-)&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;No version info available&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;To check for updates: docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;quot;&lt;br /&gt;
STATUS_EOF&lt;br /&gt;
chmod +x /var/www/html/check_status.sh&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Setup completed!&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;Update scripts installed:&amp;quot;&lt;br /&gt;
echo &amp;quot;- /var/www/html/check_updates.sh (user-friendly)&amp;quot;&lt;br /&gt;
echo &amp;quot;- /var/www/html/update_xml.sh (direct)&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Final counts&lt;br /&gt;
PAGES=$(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;0&amp;quot;)&lt;br /&gt;
echo &amp;quot;Pages imported: $PAGES&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb stop&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.4: XML Update Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano update_xml.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;=== CompleteNoobs Wiki XML Updater ===&amp;quot;&lt;br /&gt;
echo &amp;quot;This will check for new and updated pages&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Function to check if running in container&lt;br /&gt;
check_environment() {&lt;br /&gt;
    if [ ! -f &amp;quot;/var/www/html/LocalSettings.php&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;Error: This script must be run inside the wiki container&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to get current XML version&lt;br /&gt;
get_current_version() {&lt;br /&gt;
    if [ -f &amp;quot;/var/www/html/.last_import&amp;quot; ]; then&lt;br /&gt;
        grep &amp;quot;Import:&amp;quot; /var/www/html/.last_import | cut -d&#039; &#039; -f2&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;none&amp;quot;&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to check for updates&lt;br /&gt;
check_for_updates() {&lt;br /&gt;
    python3 - &amp;lt;&amp;lt; &#039;PYTHON_EOF&#039;&lt;br /&gt;
import requests&lt;br /&gt;
from bs4 import BeautifulSoup&lt;br /&gt;
import re&lt;br /&gt;
import sys&lt;br /&gt;
&lt;br /&gt;
BASE_URL = &amp;quot;https://xml.completenoobs.com/xmlDumps/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
def parse_date_from_dump(dump_name):&lt;br /&gt;
    match = re.match(r&#039;(\d{2})_(\d{2})_(\d{2})\.Noobs&#039;, dump_name)&lt;br /&gt;
    if match:&lt;br /&gt;
        day, month, year = match.groups()&lt;br /&gt;
        year_int = int(year)&lt;br /&gt;
        full_year = 2000 + year_int if year_int &amp;lt;= 49 else 1900 + year_int&lt;br /&gt;
        return (full_year, int(month), int(day))&lt;br /&gt;
    return (0, 0, 0)&lt;br /&gt;
&lt;br /&gt;
def get_latest_dump():&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(BASE_URL, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        dumps = [link.get(&#039;href&#039;).rstrip(&#039;/&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if re.match(r&#039;\d{2}_\d{2}_\d{2}\.Noobs/$&#039;, link.get(&#039;href&#039;, &#039;&#039;))]&lt;br /&gt;
        if dumps:&lt;br /&gt;
            latest = sorted(dumps, key=parse_date_from_dump, reverse=True)[0]&lt;br /&gt;
            &lt;br /&gt;
            # Get XML files from latest dump&lt;br /&gt;
            response = requests.get(f&amp;quot;{BASE_URL}{latest}/&amp;quot;, timeout=30)&lt;br /&gt;
            response.raise_for_status()&lt;br /&gt;
            soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
            files = [link.get(&#039;href&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                    if link.get(&#039;href&#039;, &#039;&#039;).endswith(&#039;.xml&#039;)]&lt;br /&gt;
            &lt;br /&gt;
            if files:&lt;br /&gt;
                newest_xml = sorted(files, reverse=True)[0]&lt;br /&gt;
                print(f&amp;quot;{latest}/{newest_xml}&amp;quot;)&lt;br /&gt;
                sys.exit(0)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;ERROR: {e}&amp;quot;, file=sys.stderr)&lt;br /&gt;
        sys.exit(1)&lt;br /&gt;
    &lt;br /&gt;
    print(&amp;quot;ERROR: No dumps found&amp;quot;, file=sys.stderr)&lt;br /&gt;
    sys.exit(1)&lt;br /&gt;
&lt;br /&gt;
get_latest_dump()&lt;br /&gt;
PYTHON_EOF&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to download new XML&lt;br /&gt;
download_xml() {&lt;br /&gt;
    local dump_info=&amp;quot;$1&amp;quot;&lt;br /&gt;
    local dump_dir=$(echo &amp;quot;$dump_info&amp;quot; | cut -d&#039;/&#039; -f1)&lt;br /&gt;
    local xml_file=$(echo &amp;quot;$dump_info&amp;quot; | cut -d&#039;/&#039; -f2)&lt;br /&gt;
    local url=&amp;quot;https://xml.completenoobs.com/xmlDumps/${dump_info}&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Downloading: $xml_file&amp;quot;&lt;br /&gt;
    echo &amp;quot;From: $dump_dir&amp;quot;&lt;br /&gt;
    echo &amp;quot;URL: $url&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if wget -O /tmp/new_dump.xml &amp;quot;$url&amp;quot; --progress=bar:force 2&amp;gt;&amp;amp;1; then&lt;br /&gt;
        echo &amp;quot;Download successful!&amp;quot;&lt;br /&gt;
        return 0&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;Download failed!&amp;quot;&lt;br /&gt;
        return 1&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to backup current database&lt;br /&gt;
backup_database() {&lt;br /&gt;
    echo &amp;quot;Creating database backup...&amp;quot;&lt;br /&gt;
    TIMESTAMP=$(date +%Y%m%d_%H%M%S)&lt;br /&gt;
    mysqldump --user=wikiuser --password=wikipass completenoobs_wiki &amp;gt; /tmp/wiki_backup_${TIMESTAMP}.sql&lt;br /&gt;
    echo &amp;quot;Backup created: /tmp/wiki_backup_${TIMESTAMP}.sql&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to analyze and import changes&lt;br /&gt;
analyze_and_import() {&lt;br /&gt;
    echo &amp;quot;Analyzing differences between XML and local wiki...&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Create analysis and import script for MediaWiki 1.44+&lt;br /&gt;
    cat &amp;gt; /tmp/analyze_import.php &amp;lt;&amp;lt; &#039;PHP_EOF&#039;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
require_once &#039;/var/www/html/maintenance/Maintenance.php&#039;;&lt;br /&gt;
&lt;br /&gt;
class AnalyzeAndImport extends Maintenance {&lt;br /&gt;
    private $db;&lt;br /&gt;
    private $new_pages = [];&lt;br /&gt;
    private $changed_pages = [];&lt;br /&gt;
    private $unchanged_pages = [];&lt;br /&gt;
    &lt;br /&gt;
    public function __construct() {&lt;br /&gt;
        parent::__construct();&lt;br /&gt;
        $this-&amp;gt;addDescription(&#039;Analyze and selectively import from XML dump&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    public function execute() {&lt;br /&gt;
        $this-&amp;gt;db = new mysqli(&#039;127.0.0.1&#039;, &#039;wikiuser&#039;, &#039;wikipass&#039;, &#039;completenoobs_wiki&#039;);&lt;br /&gt;
        &lt;br /&gt;
        // MediaWiki 1.35+ uses slots and content tables&lt;br /&gt;
        // Get existing pages with their content&lt;br /&gt;
        $query = &amp;quot;&lt;br /&gt;
            SELECT p.page_title, p.page_id, c.content_address, c.content_sha1&lt;br /&gt;
            FROM page p&lt;br /&gt;
            JOIN revision r ON p.page_latest = r.rev_id&lt;br /&gt;
            JOIN slots s ON r.rev_id = s.slot_revision_id&lt;br /&gt;
            JOIN slot_roles sr ON s.slot_role_id = sr.role_id&lt;br /&gt;
            JOIN content c ON s.slot_content_id = c.content_id&lt;br /&gt;
            WHERE p.page_namespace = 0 AND sr.role_name = &#039;main&#039;&lt;br /&gt;
        &amp;quot;;&lt;br /&gt;
        &lt;br /&gt;
        $result = $this-&amp;gt;db-&amp;gt;query($query);&lt;br /&gt;
        if (!$result) {&lt;br /&gt;
            $this-&amp;gt;error(&amp;quot;Database query failed: &amp;quot; . $this-&amp;gt;db-&amp;gt;error);&lt;br /&gt;
            return;&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $existing = [];&lt;br /&gt;
        while ($row = $result-&amp;gt;fetch_assoc()) {&lt;br /&gt;
            // Get actual text content&lt;br /&gt;
            $text_content = $this-&amp;gt;getTextContent($row[&#039;content_address&#039;]);&lt;br /&gt;
            $existing[$row[&#039;page_title&#039;]] = [&lt;br /&gt;
                &#039;id&#039; =&amp;gt; $row[&#039;page_id&#039;],&lt;br /&gt;
                &#039;content&#039; =&amp;gt; $text_content,&lt;br /&gt;
                &#039;sha1&#039; =&amp;gt; $row[&#039;content_sha1&#039;]&lt;br /&gt;
            ];&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Parse XML and compare&lt;br /&gt;
        $xml = simplexml_load_file(&#039;/tmp/new_dump.xml&#039;);&lt;br /&gt;
        &lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            $title = str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title);&lt;br /&gt;
            $xml_content = (string)$page-&amp;gt;revision-&amp;gt;text;&lt;br /&gt;
            &lt;br /&gt;
            if (!isset($existing[$title])) {&lt;br /&gt;
                // New page&lt;br /&gt;
                $this-&amp;gt;new_pages[$title] = $xml_content;&lt;br /&gt;
            } else {&lt;br /&gt;
                // Compare content using SHA1 for efficiency&lt;br /&gt;
                $xml_sha1 = sha1($xml_content);&lt;br /&gt;
                &lt;br /&gt;
                if ($existing[$title][&#039;sha1&#039;] !== $xml_sha1) {&lt;br /&gt;
                    // Content is different&lt;br /&gt;
                    $this-&amp;gt;changed_pages[$title] = [&lt;br /&gt;
                        &#039;local&#039; =&amp;gt; $existing[$title][&#039;content&#039;],&lt;br /&gt;
                        &#039;xml&#039; =&amp;gt; $xml_content,&lt;br /&gt;
                        &#039;page_id&#039; =&amp;gt; $existing[$title][&#039;id&#039;]&lt;br /&gt;
                    ];&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;unchanged_pages[] = $title;&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Display summary&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;\n=== Update Analysis ===\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;New pages to import: &amp;quot; . count($this-&amp;gt;new_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Changed pages found: &amp;quot; . count($this-&amp;gt;changed_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Unchanged pages: &amp;quot; . count($this-&amp;gt;unchanged_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        &lt;br /&gt;
        // Save analysis for review&lt;br /&gt;
        file_put_contents(&#039;/tmp/update_analysis.json&#039;, json_encode([&lt;br /&gt;
            &#039;new&#039; =&amp;gt; array_keys($this-&amp;gt;new_pages),&lt;br /&gt;
            &#039;changed&#039; =&amp;gt; array_keys($this-&amp;gt;changed_pages),&lt;br /&gt;
            &#039;unchanged&#039; =&amp;gt; $this-&amp;gt;unchanged_pages&lt;br /&gt;
        ], JSON_PRETTY_PRINT));&lt;br /&gt;
        &lt;br /&gt;
        // Show changed pages with preview (limit to first 20 for readability)&lt;br /&gt;
        if (count($this-&amp;gt;changed_pages) &amp;gt; 0) {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Changed Pages ===\n&amp;quot;);&lt;br /&gt;
            $count = 0;&lt;br /&gt;
            $total_changed = count($this-&amp;gt;changed_pages);&lt;br /&gt;
            &lt;br /&gt;
            foreach ($this-&amp;gt;changed_pages as $title =&amp;gt; $data) {&lt;br /&gt;
                $count++;&lt;br /&gt;
                if ($count &amp;lt;= 20) {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;\n$count. $title\n&amp;quot;);&lt;br /&gt;
                    &lt;br /&gt;
                    // Create a simple diff preview (first 300 chars)&lt;br /&gt;
                    $xml_preview = substr($data[&#039;xml&#039;], 0, 100);&lt;br /&gt;
                    &lt;br /&gt;
                    // Save full diff to file&lt;br /&gt;
                    $safe_title = preg_replace(&#039;/[^a-zA-Z0-9_-]/&#039;, &#039;_&#039;, $title);&lt;br /&gt;
                    $diff_file = &amp;quot;/tmp/diff_${safe_title}.txt&amp;quot;;&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;=== FULL DIFF FOR: $title ===\n\n&amp;quot;);&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;--- LOCAL VERSION ---\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, $data[&#039;local&#039;] . &amp;quot;\n\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;--- XML VERSION ---\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, $data[&#039;xml&#039;], FILE_APPEND);&lt;br /&gt;
                    &lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;   Preview: &amp;quot; . $xml_preview . &amp;quot;...\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
            &lt;br /&gt;
            if ($total_changed &amp;gt; 20) {&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;\n... and &amp;quot; . ($total_changed - 20) . &amp;quot; more changed pages.\n&amp;quot;);&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;All diff files saved to /tmp/diff_*.txt\n&amp;quot;);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Interactive selection&lt;br /&gt;
        if (count($this-&amp;gt;new_pages) &amp;gt; 0 || count($this-&amp;gt;changed_pages) &amp;gt; 0) {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Import Options ===\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;1. Import new pages only (preserve all local changes)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;2. Import new pages + update ALL changed pages (overwrites local changes)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;3. Selective import (choose which updates to apply)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;4. Cancel (no changes)\n&amp;quot;);&lt;br /&gt;
            &lt;br /&gt;
            // Save state for import script WITHOUT the XML object&lt;br /&gt;
            $import_data = [&lt;br /&gt;
                &#039;new_pages&#039; =&amp;gt; $this-&amp;gt;new_pages,&lt;br /&gt;
                &#039;changed_pages&#039; =&amp;gt; $this-&amp;gt;changed_pages,&lt;br /&gt;
                &#039;xml_file&#039; =&amp;gt; &#039;/tmp/new_dump.xml&#039;  // Save path instead of object&lt;br /&gt;
            ];&lt;br /&gt;
            &lt;br /&gt;
            file_put_contents(&#039;/tmp/import_data.ser&#039;, serialize($import_data));&lt;br /&gt;
        } else {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\nNo changes detected. Your wiki is up to date!\n&amp;quot;);&lt;br /&gt;
        }&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function getTextContent($address) {&lt;br /&gt;
        // Handle different content storage formats in MW 1.35+&lt;br /&gt;
        if (strpos($address, &#039;tt:&#039;) === 0) {&lt;br /&gt;
            // Text table reference&lt;br /&gt;
            $text_id = substr($address, 3);&lt;br /&gt;
            $result = $this-&amp;gt;db-&amp;gt;query(&amp;quot;SELECT old_text FROM text WHERE old_id = $text_id&amp;quot;);&lt;br /&gt;
            if ($row = $result-&amp;gt;fetch_assoc()) {&lt;br /&gt;
                return $row[&#039;old_text&#039;];&lt;br /&gt;
            }&lt;br /&gt;
        } elseif (strpos($address, &#039;es:&#039;) === 0) {&lt;br /&gt;
            // External storage - would need special handling&lt;br /&gt;
            return &amp;quot;[External storage content]&amp;quot;;&lt;br /&gt;
        }&lt;br /&gt;
        // Direct content&lt;br /&gt;
        return $address;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
$maintClass = AnalyzeAndImport::class;&lt;br /&gt;
require_once RUN_MAINTENANCE_IF_MAIN;&lt;br /&gt;
PHP_EOF&lt;br /&gt;
&lt;br /&gt;
    cd /var/www/html&lt;br /&gt;
    php /tmp/analyze_import.php&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to perform selected import&lt;br /&gt;
perform_import() {&lt;br /&gt;
    local choice=$1&lt;br /&gt;
    &lt;br /&gt;
    cat &amp;gt; /tmp/do_import.php &amp;lt;&amp;lt; &#039;PHP_EOF&#039;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
require_once &#039;/var/www/html/maintenance/Maintenance.php&#039;;&lt;br /&gt;
&lt;br /&gt;
class DoImport extends Maintenance {&lt;br /&gt;
    public function __construct() {&lt;br /&gt;
        parent::__construct();&lt;br /&gt;
        $this-&amp;gt;addOption(&#039;mode&#039;, &#039;Import mode&#039;, true, true);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    public function execute() {&lt;br /&gt;
        $mode = $this-&amp;gt;getOption(&#039;mode&#039;);&lt;br /&gt;
        $data = unserialize(file_get_contents(&#039;/tmp/import_data.ser&#039;));&lt;br /&gt;
        &lt;br /&gt;
        // Load XML file&lt;br /&gt;
        $xml = simplexml_load_file($data[&#039;xml_file&#039;]);&lt;br /&gt;
        &lt;br /&gt;
        $new_imported = 0;&lt;br /&gt;
        $updated = 0;&lt;br /&gt;
        &lt;br /&gt;
        // Import new pages (always for modes 1-3)&lt;br /&gt;
        if ($mode != &#039;4&#039;) {&lt;br /&gt;
            foreach ($data[&#039;new_pages&#039;] as $title =&amp;gt; $content) {&lt;br /&gt;
                $this-&amp;gt;importPage($title, $content, $xml);&lt;br /&gt;
                $new_imported++;&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Imported new page: $title\n&amp;quot;);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Handle changed pages based on mode&lt;br /&gt;
        if ($mode == &#039;2&#039;) {&lt;br /&gt;
            // Update all changed pages&lt;br /&gt;
            foreach ($data[&#039;changed_pages&#039;] as $title =&amp;gt; $info) {&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Updating: $title\n&amp;quot;);&lt;br /&gt;
                if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                    $updated++;&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Updated page: $title\n&amp;quot;);&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Failed to update: $title\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        } elseif ($mode == &#039;3&#039;) {&lt;br /&gt;
            // Selective update&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Selective Import Mode ===\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;For each changed page, choose:\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  y = yes, update this page\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  n = no, keep local version\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  d = show diff file\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  a = update all remaining pages\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  s = skip all remaining pages\n\n&amp;quot;);&lt;br /&gt;
            &lt;br /&gt;
            $update_all = false;&lt;br /&gt;
            $skip_all = false;&lt;br /&gt;
            &lt;br /&gt;
            foreach ($data[&#039;changed_pages&#039;] as $title =&amp;gt; $info) {&lt;br /&gt;
                if ($skip_all) {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                    continue;&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                if ($update_all) {&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                    continue;&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;\nPage: $title\n&amp;quot;);&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Action (y/n/d/a/s): &amp;quot;);&lt;br /&gt;
                $handle = fopen(&amp;quot;php://stdin&amp;quot;, &amp;quot;r&amp;quot;);&lt;br /&gt;
                $line = trim(fgets($handle));&lt;br /&gt;
                &lt;br /&gt;
                while ($line == &#039;d&#039;) {&lt;br /&gt;
                    // Show diff&lt;br /&gt;
                    $safe_title = preg_replace(&#039;/[^a-zA-Z0-9_-]/&#039;, &#039;_&#039;, $title);&lt;br /&gt;
                    $diff_file = &amp;quot;/tmp/diff_${safe_title}.txt&amp;quot;;&lt;br /&gt;
                    if (file_exists($diff_file)) {&lt;br /&gt;
                        system(&amp;quot;head -50 $diff_file&amp;quot;);&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;\n[Showing first 50 lines - full file at $diff_file]\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Action (y/n/a/s): &amp;quot;);&lt;br /&gt;
                    $line = trim(fgets($handle));&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                if ($line == &#039;a&#039;) {&lt;br /&gt;
                    $update_all = true;&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                } elseif ($line == &#039;s&#039;) {&lt;br /&gt;
                    $skip_all = true;&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                } elseif ($line == &#039;y&#039;) {&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Failed to update: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;\n=== Import Complete ===\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;New pages imported: $new_imported\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Pages updated: $updated\n&amp;quot;);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function importPage($title, $content, $xml) {&lt;br /&gt;
        // Create single page XML for import&lt;br /&gt;
        $tempFile = &#039;/tmp/single_page_&#039; . md5($title) . &#039;.xml&#039;;&lt;br /&gt;
        $singlePage = new SimpleXMLElement(&#039;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;&amp;lt;mediawiki&amp;gt;&amp;lt;/mediawiki&amp;gt;&#039;);&lt;br /&gt;
        &lt;br /&gt;
        if ($xml-&amp;gt;siteinfo) {&lt;br /&gt;
            $siteinfo = $singlePage-&amp;gt;addChild(&#039;siteinfo&#039;);&lt;br /&gt;
            foreach ($xml-&amp;gt;siteinfo-&amp;gt;children() as $child) {&lt;br /&gt;
                $siteinfo-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Find and add the page&lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            if (str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title) == $title) {&lt;br /&gt;
                $newPage = $singlePage-&amp;gt;addChild(&#039;page&#039;);&lt;br /&gt;
                foreach ($page-&amp;gt;children() as $child) {&lt;br /&gt;
                    if ($child-&amp;gt;getName() == &#039;revision&#039;) {&lt;br /&gt;
                        $revision = $newPage-&amp;gt;addChild(&#039;revision&#039;);&lt;br /&gt;
                        foreach ($child-&amp;gt;children() as $revChild) {&lt;br /&gt;
                            $revision-&amp;gt;addChild($revChild-&amp;gt;getName(), (string)$revChild);&lt;br /&gt;
                        }&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $newPage-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                break;&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $singlePage-&amp;gt;asXML($tempFile);&lt;br /&gt;
        exec(&amp;quot;php /var/www/html/maintenance/importDump.php &amp;lt; $tempFile 2&amp;gt;&amp;amp;1&amp;quot;);&lt;br /&gt;
        unlink($tempFile);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function reimportPage($title, $xml) {&lt;br /&gt;
        // For updating existing pages, delete then reimport&lt;br /&gt;
        $db = new mysqli(&#039;127.0.0.1&#039;, &#039;wikiuser&#039;, &#039;wikipass&#039;, &#039;completenoobs_wiki&#039;);&lt;br /&gt;
        &lt;br /&gt;
        // Delete the existing page&lt;br /&gt;
        $safe_title = $db-&amp;gt;real_escape_string(str_replace(&#039; &#039;, &#039;_&#039;, $title));&lt;br /&gt;
        $db-&amp;gt;query(&amp;quot;DELETE FROM page WHERE page_title = &#039;$safe_title&#039; AND page_namespace = 0&amp;quot;);&lt;br /&gt;
        &lt;br /&gt;
        $db-&amp;gt;close();&lt;br /&gt;
        &lt;br /&gt;
        // Now import the new version&lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            if (str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title) == $title) {&lt;br /&gt;
                $tempFile = &#039;/tmp/update_page_&#039; . md5($title) . &#039;.xml&#039;;&lt;br /&gt;
                $singlePage = new SimpleXMLElement(&#039;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;&amp;lt;mediawiki&amp;gt;&amp;lt;/mediawiki&amp;gt;&#039;);&lt;br /&gt;
                &lt;br /&gt;
                if ($xml-&amp;gt;siteinfo) {&lt;br /&gt;
                    $siteinfo = $singlePage-&amp;gt;addChild(&#039;siteinfo&#039;);&lt;br /&gt;
                    foreach ($xml-&amp;gt;siteinfo-&amp;gt;children() as $child) {&lt;br /&gt;
                        $siteinfo-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $newPage = $singlePage-&amp;gt;addChild(&#039;page&#039;);&lt;br /&gt;
                foreach ($page-&amp;gt;children() as $child) {&lt;br /&gt;
                    if ($child-&amp;gt;getName() == &#039;revision&#039;) {&lt;br /&gt;
                        $revision = $newPage-&amp;gt;addChild(&#039;revision&#039;);&lt;br /&gt;
                        foreach ($child-&amp;gt;children() as $revChild) {&lt;br /&gt;
                            $revision-&amp;gt;addChild($revChild-&amp;gt;getName(), (string)$revChild);&lt;br /&gt;
                        }&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $newPage-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $singlePage-&amp;gt;asXML($tempFile);&lt;br /&gt;
                $result = exec(&amp;quot;php /var/www/html/maintenance/importDump.php &amp;lt; $tempFile 2&amp;gt;&amp;amp;1&amp;quot;, $output, $return);&lt;br /&gt;
                unlink($tempFile);&lt;br /&gt;
                &lt;br /&gt;
                return ($return === 0);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        return false;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
$maintClass = DoImport::class;&lt;br /&gt;
require_once RUN_MAINTENANCE_IF_MAIN;&lt;br /&gt;
PHP_EOF&lt;br /&gt;
&lt;br /&gt;
    cd /var/www/html&lt;br /&gt;
    php /tmp/do_import.php --mode=&amp;quot;$choice&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Main execution&lt;br /&gt;
main() {&lt;br /&gt;
    check_environment&lt;br /&gt;
    &lt;br /&gt;
    # Start MariaDB if not running&lt;br /&gt;
    service mariadb status &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 || service mariadb start&lt;br /&gt;
    &lt;br /&gt;
    # Wait for MariaDB&lt;br /&gt;
    for i in {1..30}; do&lt;br /&gt;
        if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
            break&lt;br /&gt;
        fi&lt;br /&gt;
        sleep 1&lt;br /&gt;
    done&lt;br /&gt;
    &lt;br /&gt;
    CURRENT=$(get_current_version)&lt;br /&gt;
    echo &amp;quot;Current version: $CURRENT&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Checking for updates...&amp;quot;&lt;br /&gt;
    LATEST=$(check_for_updates 2&amp;gt;/dev/null)&lt;br /&gt;
    if [ $? -ne 0 ] || [ -z &amp;quot;$LATEST&amp;quot; ] || [[ &amp;quot;$LATEST&amp;quot; == *&amp;quot;ERROR&amp;quot;* ]]; then&lt;br /&gt;
        echo &amp;quot;Failed to check for updates&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Latest available: $LATEST&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Always proceed to analysis even if versions match&lt;br /&gt;
    # (there might be content updates in the same version)&lt;br /&gt;
    echo &amp;quot;Proceeding with content analysis...&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Backup database&lt;br /&gt;
    backup_database&lt;br /&gt;
    &lt;br /&gt;
    # Download new XML&lt;br /&gt;
    if ! download_xml &amp;quot;$LATEST&amp;quot;; then&lt;br /&gt;
        echo &amp;quot;Failed to download new XML&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Analyze differences&lt;br /&gt;
    analyze_and_import&lt;br /&gt;
    &lt;br /&gt;
    # Check if there are changes to import&lt;br /&gt;
    if [ -f &amp;quot;/tmp/import_data.ser&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;&amp;quot;&lt;br /&gt;
        read -p &amp;quot;Choose option (1-4): &amp;quot; -n 1 -r&lt;br /&gt;
        echo&lt;br /&gt;
        &lt;br /&gt;
        if [[ $REPLY =~ ^[1-4]$ ]]; then&lt;br /&gt;
            if [ &amp;quot;$REPLY&amp;quot; != &amp;quot;4&amp;quot; ]; then&lt;br /&gt;
                perform_import &amp;quot;$REPLY&amp;quot;&lt;br /&gt;
                &lt;br /&gt;
                # Update version info&lt;br /&gt;
                echo &amp;quot;Import: $LATEST&amp;quot; &amp;gt; /var/www/html/.last_import&lt;br /&gt;
                echo &amp;quot;Date: $(date)&amp;quot; &amp;gt;&amp;gt; /var/www/html/.last_import&lt;br /&gt;
                &lt;br /&gt;
                # Rebuild indices&lt;br /&gt;
                echo &amp;quot;Rebuilding indices...&amp;quot;&lt;br /&gt;
                php maintenance/rebuildrecentchanges.php&lt;br /&gt;
                php maintenance/initSiteStats.php&lt;br /&gt;
            else&lt;br /&gt;
                echo &amp;quot;Update cancelled&amp;quot;&lt;br /&gt;
            fi&lt;br /&gt;
        else&lt;br /&gt;
            echo &amp;quot;Invalid option. Update cancelled&amp;quot;&lt;br /&gt;
        fi&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Clean up temp files&lt;br /&gt;
    rm -f /tmp/import_data.ser /tmp/update_analysis.json /tmp/diff_*.txt /tmp/analyze_import.php /tmp/do_import.php 2&amp;gt;/dev/null&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    echo &amp;quot;Done!&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Run main function&lt;br /&gt;
main &amp;quot;$@&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.5: Entrypoint Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano entrypoint.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Starting CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 1&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;CompleteNoobs Wiki ready at: http://localhost:8080&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin login: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Features:&amp;quot;&lt;br /&gt;
echo &amp;quot;- Complete wiki content imported from XML&amp;quot;&lt;br /&gt;
echo &amp;quot;- License notices on all pages (via PageNotice)&amp;quot;&lt;br /&gt;
echo &amp;quot;- SyntaxHighlight for code blocks&amp;quot;&lt;br /&gt;
echo &amp;quot;- YouTube video embedding&amp;quot;&lt;br /&gt;
echo &amp;quot;- Contribution Scores special page&amp;quot;&lt;br /&gt;
echo &amp;quot;- XML update system (preserves local edits)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;To check for updates: docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
apache2-foreground&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 3: Build and Run ==&lt;br /&gt;
&lt;br /&gt;
=== 3.1: Build the Image ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker build -t completenoobs/wiki:latest .&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This will take several minutes.&lt;br /&gt;
&lt;br /&gt;
=== 3.2: Run the Container ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki completenoobs/wiki:latest&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* NOTE: The above &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; command is for quick testing, if you want to be able to export your wiki&#039;s database to an XML file you can backup and share, please use method in expanding info box below. &lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
If you want to be able to Export Your MediaWiki Database to Dated XML File - use this &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; method:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To export your MediaWiki database to a dated XML file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) and save it to the host’s &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory, run the export script inside the Docker container and use a volume mount to write the file to the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Create Host Directory&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, ensure the &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory exists:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Run Container with Volume Mount&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If your container isn’t already using a volume for &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt;, stop and remove it, then restart with a volume mount:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Run Export Script in Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Access the container’s shell:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Inside the container, run the export script to create a dated XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
DATE=$(date +%Y%m%d)&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:/export/$DATE.xml&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This writes the file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt; in the container, which maps to &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; on the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify the File&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, check for the XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ls ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
You should see a file like &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Notes&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
- If you encounter permission issues, ensure the container’s user has write access to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt;:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
chmod -R 777 /export&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- The script must be run inside the container, as it requires MediaWiki’s environment and database access.&amp;lt;br&amp;gt;&lt;br /&gt;
- If your container uses a different volume setup, adjust the mount point accordingly.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 4: Test Everything ==&lt;br /&gt;
&lt;br /&gt;
=== 4.1: Check the Wiki ===&lt;br /&gt;
* Visit: http://localhost:8080&lt;br /&gt;
* You should see the PageNotice at the top&lt;br /&gt;
* Login with: admin / AdminPass123!&lt;br /&gt;
{{:Restore_the_completenoobs_Main_Page}}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Change Admin Password:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
By default, the admin user&#039;s password is &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;. It&#039;s highly recommended to change this immediately. You can do this either through the wiki&#039;s web interface or directly in the Docker terminal.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 1: Change Password via Web Interface&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
This is the easiest method. You can change your password directly from the wiki itself.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. Log in to your wiki with the default credentials: &amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
2. Once logged in, click your username (&amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt;) in the top-right corner of the page.&amp;lt;br&amp;gt;&lt;br /&gt;
3. From the drop-down menu, select &amp;lt;b&amp;gt;Preferences&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
4. On the Preferences page, go to the &amp;lt;b&amp;gt;Password&amp;lt;/b&amp;gt; tab.&amp;lt;br&amp;gt;&lt;br /&gt;
5. Enter the current password (&amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;), then enter your new password twice.&amp;lt;br&amp;gt;&lt;br /&gt;
6. Click &amp;lt;b&amp;gt;Change password&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Your password is now changed, and you will need to use the new one for future logins.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 2: Change Password via Terminal (No-Email Reset)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you have forgotten the password or prefer to use the command line, you can reset it directly inside the Docker container using a MediaWiki maintenance script.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. &amp;lt;b&amp;gt;Access the container&#039;s shell&amp;lt;/b&amp;gt; with the following command from your host machine:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
2. Once inside the container, use the &amp;lt;code&amp;gt;changePassword.php&amp;lt;/code&amp;gt; maintenance script to change the password. This is the modern, recommended way to run MediaWiki maintenance scripts.&amp;lt;br&amp;gt;&lt;br /&gt;
* Change &amp;lt;b&amp;gt;NEWPASSWORD&amp;lt;/b&amp;gt; to your new password&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
3. Type &amp;lt;code&amp;gt;exit&amp;lt;/code&amp;gt; to leave the container&#039;s shell.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The admin password has now been reset. You can log in to your wiki with the new password.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.2: Test Extensions ===&lt;br /&gt;
* &#039;&#039;&#039;YouTube&#039;&#039;&#039;: Edit any page, add &amp;lt;code&amp;gt;&amp;lt;youtube&amp;gt;N9qYF9DZPdw&amp;lt;/youtube&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;PageNotice&#039;&#039;&#039;: Should already be visible at the top&lt;br /&gt;
* &#039;&#039;&#039;SyntaxHighlight&#039;&#039;&#039;: Add code blocks with &amp;lt;nowiki&amp;gt;&amp;lt;code&amp;gt;&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;code here&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/nowiki&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.3: Check Status ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/check_status.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 5: XML Update Operations ==&lt;br /&gt;
*NOTE: update_xml.sh still needs alot of work, this just idea placeholder for now.&lt;br /&gt;
=== 5.1: Check for Updates (Interactive) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This will:&lt;br /&gt;
* Check the CompleteNoobs XML repository for new dumps&lt;br /&gt;
* Compare with your current version&lt;br /&gt;
* Ask for confirmation before updating&lt;br /&gt;
* Import ONLY new pages (preserves your edits)&lt;br /&gt;
* Create a backup before making changes&lt;br /&gt;
&lt;br /&gt;
=== 5.2: Force Update (Non-Interactive) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki bash -c &amp;quot;echo &#039;y&#039; | /var/www/html/update_xml.sh&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5.3: Manual Update Process ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Enter container&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&lt;br /&gt;
# 2. Check current version&lt;br /&gt;
cat /var/www/html/.last_import&lt;br /&gt;
&lt;br /&gt;
# 3. Run update&lt;br /&gt;
/var/www/html/check_updates.sh&lt;br /&gt;
&lt;br /&gt;
# 4. Exit container&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 6: Troubleshooting Commands ==&lt;br /&gt;
=== Access container shell: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Edit configuration: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki nano /var/www/html/LocalSettings.php&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Check logs: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== View update logs: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki tail -f /tmp/mediawiki-debug.log&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Complete restart: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki completenoobs/wiki:latest&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Expected Results ==&lt;br /&gt;
* Working wiki with imported CompleteNoobs content&lt;br /&gt;
* PageNotice visible at top of all pages&lt;br /&gt;
* All extensions functional&lt;br /&gt;
* Text editors (nano/vim) available in container&lt;br /&gt;
* Utility scripts for maintenance&lt;br /&gt;
* XML update system that preserves local edits&lt;br /&gt;
&lt;br /&gt;
==need to add==&lt;br /&gt;
* way for user to backup there local custom wiki - xml exporter&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Restore_the_completenoobs_Main_Page&amp;diff=668</id>
		<title>Restore the completenoobs Main Page</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Restore_the_completenoobs_Main_Page&amp;diff=668"/>
		<updated>2025-09-02T10:21:02Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: Created page with &amp;quot;&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt; Restore the completenoobs Main Page: &amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;  By default, the &amp;lt;code&amp;gt;completenoobs&amp;lt;/code&amp;gt; MediaWiki instance overwrites the Main Page with content like &amp;quot;&amp;lt;strong&amp;gt;MediaWiki has been installed.&amp;lt;/strong&amp;gt;&amp;quot; To revert to the page’s state before this change, undo the initial revision:&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;  1. Go to &amp;lt;code&amp;gt;http://localhost:8080&amp;lt;/code&amp;gt; in your browser.&amp;lt;br&amp;gt; 2. On the Main Page, click &amp;lt;b&amp;gt;View Histo...&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Restore the completenoobs Main Page:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
By default, the &amp;lt;code&amp;gt;completenoobs&amp;lt;/code&amp;gt; MediaWiki instance overwrites the Main Page with content like &amp;quot;&amp;lt;strong&amp;gt;MediaWiki has been installed.&amp;lt;/strong&amp;gt;&amp;quot; To revert to the page’s state before this change, undo the initial revision:&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. Go to &amp;lt;code&amp;gt;http://localhost:8080&amp;lt;/code&amp;gt; in your browser.&amp;lt;br&amp;gt;&lt;br /&gt;
2. On the Main Page, click &amp;lt;b&amp;gt;View History&amp;lt;/b&amp;gt; (top-right corner).&amp;lt;br&amp;gt;&lt;br /&gt;
3. Find the top revision by &amp;lt;code&amp;gt;MediaWiki default&amp;lt;/code&amp;gt; and click &amp;lt;b&amp;gt;Undo&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
4. Scroll down and click &amp;lt;b&amp;gt;Save changes&amp;lt;/b&amp;gt; to revert the Main Page.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Ubuntu2404_Install_Docker_and_Docker_Compose&amp;diff=667</id>
		<title>Ubuntu2404 Install Docker and Docker Compose</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Ubuntu2404_Install_Docker_and_Docker_Compose&amp;diff=667"/>
		<updated>2025-09-01T21:16:15Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Install First Container/Image */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;=== Preparation ===&lt;br /&gt;
&lt;br /&gt;
Before we begin, make sure you&#039;re logged in with a user account that has sudo privileges.&lt;br /&gt;
&lt;br /&gt;
=== Update System Packages ===&lt;br /&gt;
&lt;br /&gt;
Update your package list to ensure you have the latest versions of packages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Install Docker Prerequisites ===&lt;br /&gt;
&lt;br /&gt;
Install the necessary packages for Docker setup:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo apt install -y apt-transport-https ca-certificates curl software-properties-common&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Setup Docker Repository ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Add Docker&#039;s Official GPG Key&#039;&#039;&#039;:&lt;br /&gt;
 &lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Add the Docker Repository&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  echo &amp;quot;deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable&amp;quot; | sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Install Docker and Docker Compose ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Update Package List Again&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo apt update&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Install Docker Engine, CLI, Containerd, and Additional Tools&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo apt install -y docker-ce docker-ce-cli containerd.io python3-bs4 python3-requests docker-compose&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;!-- &lt;br /&gt;
* &#039;&#039;&#039;Install Docker Compose&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
Here we&#039;re downloading the latest version of Docker Compose:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo curl -L &amp;quot;https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)&amp;quot; -o /usr/local/bin/docker-compose&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Make the Docker Compose binary executable:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo chmod +x /usr/local/bin/docker-compose&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
--&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Verify Installation ===&lt;br /&gt;
&lt;br /&gt;
Check if Docker and Docker Compose are installed correctly:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker --version&lt;br /&gt;
docker-compose --version&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Configure User Permissions ===&lt;br /&gt;
&lt;br /&gt;
To run Docker commands without &amp;lt;code&amp;gt;sudo&amp;lt;/code&amp;gt;, add your user to the &amp;lt;code&amp;gt;docker&amp;lt;/code&amp;gt; group:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo usermod -aG docker $USER&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Note&#039;&#039;&#039;: After adding your user to the docker group, you&#039;ll need to &#039;&#039;&#039;log out and log back in&#039;&#039;&#039; for the changes to take effect.&lt;br /&gt;
If you do not log out and back in, Or you do not add your $USER to the docker group, you will be required to use sudo in some cases. such as ..&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
a way to apply group changes without logging out and back in - tip:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
exec sudo su -l $USER&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This command will replace your current shell with a new login shell for your user, which will have the updated group memberships. Both of these methods will apply the group changes immediately, allowing you to use LXD commands without having to log out and back in. &lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Remember&amp;lt;/b&amp;gt;, these changes only apply to the current terminal session. If you open a new terminal window, you might need to run the command again or log out and back in for the changes to take effect system-wide.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===If Installing Docker messed up your LXC/LXD Networking===&lt;br /&gt;
To resolve networking conflicts between Docker and LXC containers on Ubuntu 24.04, enable IP forwarding on the host system:&lt;br /&gt;
* Open the sysctl configuration file in your preferred editor&lt;br /&gt;
&amp;lt;code&amp;gt;sudo $EDITOR /etc/sysctl.conf&amp;lt;/code&amp;gt;&lt;br /&gt;
* Uncomment or add the following line (around line 28):&lt;br /&gt;
&amp;lt;pre&amp;gt;net.ipv4.ip_forward=1&amp;lt;/pre&amp;gt;&lt;br /&gt;
* Apply the updated configuration to enable IP forwarding:&lt;br /&gt;
&amp;lt;code&amp;gt;sysctl -p&amp;lt;/code&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
* Restart the system to ensure all changes take effect.&lt;br /&gt;
&lt;br /&gt;
This should resolve the networking issue for LXC containers when Docker is installed.&lt;br /&gt;
&lt;br /&gt;
==Install First Container/Image==&lt;br /&gt;
&lt;br /&gt;
Download the completenoobs container image, mediawiki with the completenoobs xml installed.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker pull completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Run container===&lt;br /&gt;
&lt;br /&gt;
* Quick Start&lt;br /&gt;
&amp;lt;code&amp;gt;docker run -d -p 8080:80 completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Quick Start with Persistent Storage&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* NOTE: The above &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; command is for quick testing, if you want to be able to export your wiki&#039;s database to an XML file you can backup and share, please use method in expanding info box below. &lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
If you want to be able to Export Your MediaWiki Database to Dated XML File - use this &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; method:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To export your MediaWiki database to a dated XML file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) and save it to the host’s &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory, run the export script inside the Docker container and use a volume mount to write the file to the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Create Host Directory&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, ensure the &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory exists:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Run Container with Volume Mount&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If your container isn’t already using a volume for &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt;, stop and remove it, then restart with a volume mount:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Run Export Script in Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Access the container’s shell:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Inside the container, run the export script to create a dated XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
DATE=$(date +%Y%m%d)&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:/export/$DATE.xml&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This writes the file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt; in the container, which maps to &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; on the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify the File&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, check for the XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ls ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
You should see a file like &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Notes&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
- If you encounter permission issues, ensure the container’s user has write access to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt;:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
chmod -R 777 /export&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- The script must be run inside the container, as it requires MediaWiki’s environment and database access.&amp;lt;br&amp;gt;&lt;br /&gt;
- If your container uses a different volume setup, adjust the mount point accordingly.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Import MediaWiki XML File from Host Directory:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To import a &amp;lt;code&amp;gt;wiki.xml&amp;lt;/code&amp;gt; file from the host’s &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory into your MediaWiki instance running in a Docker container, use the &amp;lt;code&amp;gt;importDump.php&amp;lt;/code&amp;gt; script inside the container. The &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory is mounted as &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt; in the container, allowing the container to read the file.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Place the XML File&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, move or copy the &amp;lt;code&amp;gt;wiki.xml&amp;lt;/code&amp;gt; file to &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt;:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mv ~/Downloads/wiki.xml ~/wiki-container/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Verify the file is present:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ls ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Access the Container’s Shell&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Enter the container’s shell:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Run the Import Script&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Inside the container, import the &amp;lt;code&amp;gt;wiki.xml&amp;lt;/code&amp;gt; file:&amp;lt;br&amp;gt;&lt;br /&gt;
Can check file is present in container with &amp;lt;code&amp;gt;ls /export/&amp;lt;/code&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php /export/wiki.xml&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Note: This imports the XML content into the MediaWiki database. For large files, this may take time.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Rebuild Wiki Indexes (Optional but Recommended)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Rebuild the wiki’s indexes to ensure imported content is accessible:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php rebuildall.php&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 5: Exit the Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Exit the container’s shell:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 6: Verify the Import&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Visit your wiki (e.g., &amp;lt;code&amp;gt;http://localhost:8080&amp;lt;/code&amp;gt;) to check if the imported pages appear. If issues arise, check the container logs:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Notes&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
- Ensure the container has read access to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt;. Fix permissions if needed:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
chmod -R 777 /export&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- If you want to overwrite existing pages, use the &amp;lt;code&amp;gt;--no-updates&amp;lt;/code&amp;gt; flag with &amp;lt;code&amp;gt;importDump.php&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
- If the container wasn’t started with &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; mounted as &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt;, restart it with:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 -v ~/wiki-container:/export --name completenoobs_wiki completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Now visit http://localhost:8080 on your browser&lt;br /&gt;
&lt;br /&gt;
* Due to (unknown) bug you might need to update the xml to download missing pages:&lt;br /&gt;
&amp;lt;code&amp;gt;docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Change Admin Password:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
By default, the admin user&#039;s password is &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;. It&#039;s highly recommended to change this immediately. You can do this either through the wiki&#039;s web interface or directly in the Docker terminal.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 1: Change Password via Web Interface&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
This is the easiest method. You can change your password directly from the wiki itself.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. Log in to your wiki with the default credentials: &amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
2. Once logged in, click your username (&amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt;) in the top-right corner of the page.&amp;lt;br&amp;gt;&lt;br /&gt;
3. From the drop-down menu, select &amp;lt;b&amp;gt;Preferences&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
4. On the Preferences page, go to the &amp;lt;b&amp;gt;Password&amp;lt;/b&amp;gt; tab.&amp;lt;br&amp;gt;&lt;br /&gt;
5. Enter the current password (&amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;), then enter your new password twice.&amp;lt;br&amp;gt;&lt;br /&gt;
6. Click &amp;lt;b&amp;gt;Change password&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Your password is now changed, and you will need to use the new one for future logins.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 2: Change Password via Terminal (No-Email Reset)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you have forgotten the password or prefer to use the command line, you can reset it directly inside the Docker container using a MediaWiki maintenance script.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. &amp;lt;b&amp;gt;Access the container&#039;s shell&amp;lt;/b&amp;gt; with the following command from your host machine:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
2. Once inside the container, use the &amp;lt;code&amp;gt;changePassword.php&amp;lt;/code&amp;gt; maintenance script to change the password. This is the modern, recommended way to run MediaWiki maintenance scripts.&amp;lt;br&amp;gt;&lt;br /&gt;
* Change &amp;lt;b&amp;gt;NEWPASSWORD&amp;lt;/b&amp;gt; to your new password&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
3. Type &amp;lt;code&amp;gt;exit&amp;lt;/code&amp;gt; to leave the container&#039;s shell.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The admin password has now been reset. You can log in to your wiki with the new password.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Remove container===&lt;br /&gt;
&lt;br /&gt;
To completely remove the &amp;lt;code&amp;gt;completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; container and image from your Ubuntu 24.04 system, follow these steps. You can also remove associated persistent storage volumes if they were created.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Stop and Remove the Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If the container is running, stop it and then remove it.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Alternatively, stop and remove in one command:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker rm -f completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Remove the Docker Image&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Remove the &amp;lt;code&amp;gt;completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; image.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker rmi completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Note: If the image is in use by other containers, remove those containers first or use &amp;lt;code&amp;gt;docker rmi -f completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; to force removal (use with caution).&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Remove Persistent Storage Volumes (Optional)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you used persistent storage, remove the associated volumes to free up space.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker volume rm completenoobs_mysql completenoobs_images&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Note: Ensure no other containers are using these volumes, as this will delete all stored data.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify Removal&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Check that the container, image, and volumes are removed.&amp;lt;br&amp;gt;&lt;br /&gt;
- List all containers (including stopped ones):&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker ps -a&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- List all images:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker images&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- List all volumes:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker volume ls&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
If any items remain, repeat the relevant removal commands or check for dependencies.&lt;br /&gt;
&lt;br /&gt;
==Docker Compose container==&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Ubuntu2404_Install_Docker_and_Docker_Compose&amp;diff=666</id>
		<title>Ubuntu2404 Install Docker and Docker Compose</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Ubuntu2404_Install_Docker_and_Docker_Compose&amp;diff=666"/>
		<updated>2025-09-01T21:13:29Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Run container */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;=== Preparation ===&lt;br /&gt;
&lt;br /&gt;
Before we begin, make sure you&#039;re logged in with a user account that has sudo privileges.&lt;br /&gt;
&lt;br /&gt;
=== Update System Packages ===&lt;br /&gt;
&lt;br /&gt;
Update your package list to ensure you have the latest versions of packages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Install Docker Prerequisites ===&lt;br /&gt;
&lt;br /&gt;
Install the necessary packages for Docker setup:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo apt install -y apt-transport-https ca-certificates curl software-properties-common&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Setup Docker Repository ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Add Docker&#039;s Official GPG Key&#039;&#039;&#039;:&lt;br /&gt;
 &lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Add the Docker Repository&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  echo &amp;quot;deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable&amp;quot; | sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Install Docker and Docker Compose ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Update Package List Again&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo apt update&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Install Docker Engine, CLI, Containerd, and Additional Tools&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo apt install -y docker-ce docker-ce-cli containerd.io python3-bs4 python3-requests docker-compose&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;!-- &lt;br /&gt;
* &#039;&#039;&#039;Install Docker Compose&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
Here we&#039;re downloading the latest version of Docker Compose:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo curl -L &amp;quot;https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)&amp;quot; -o /usr/local/bin/docker-compose&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Make the Docker Compose binary executable:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo chmod +x /usr/local/bin/docker-compose&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
--&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Verify Installation ===&lt;br /&gt;
&lt;br /&gt;
Check if Docker and Docker Compose are installed correctly:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker --version&lt;br /&gt;
docker-compose --version&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Configure User Permissions ===&lt;br /&gt;
&lt;br /&gt;
To run Docker commands without &amp;lt;code&amp;gt;sudo&amp;lt;/code&amp;gt;, add your user to the &amp;lt;code&amp;gt;docker&amp;lt;/code&amp;gt; group:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo usermod -aG docker $USER&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Note&#039;&#039;&#039;: After adding your user to the docker group, you&#039;ll need to &#039;&#039;&#039;log out and log back in&#039;&#039;&#039; for the changes to take effect.&lt;br /&gt;
If you do not log out and back in, Or you do not add your $USER to the docker group, you will be required to use sudo in some cases. such as ..&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
a way to apply group changes without logging out and back in - tip:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
exec sudo su -l $USER&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This command will replace your current shell with a new login shell for your user, which will have the updated group memberships. Both of these methods will apply the group changes immediately, allowing you to use LXD commands without having to log out and back in. &lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Remember&amp;lt;/b&amp;gt;, these changes only apply to the current terminal session. If you open a new terminal window, you might need to run the command again or log out and back in for the changes to take effect system-wide.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===If Installing Docker messed up your LXC/LXD Networking===&lt;br /&gt;
To resolve networking conflicts between Docker and LXC containers on Ubuntu 24.04, enable IP forwarding on the host system:&lt;br /&gt;
* Open the sysctl configuration file in your preferred editor&lt;br /&gt;
&amp;lt;code&amp;gt;sudo $EDITOR /etc/sysctl.conf&amp;lt;/code&amp;gt;&lt;br /&gt;
* Uncomment or add the following line (around line 28):&lt;br /&gt;
&amp;lt;pre&amp;gt;net.ipv4.ip_forward=1&amp;lt;/pre&amp;gt;&lt;br /&gt;
* Apply the updated configuration to enable IP forwarding:&lt;br /&gt;
&amp;lt;code&amp;gt;sysctl -p&amp;lt;/code&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
* Restart the system to ensure all changes take effect.&lt;br /&gt;
&lt;br /&gt;
This should resolve the networking issue for LXC containers when Docker is installed.&lt;br /&gt;
&lt;br /&gt;
==Install First Container/Image==&lt;br /&gt;
&lt;br /&gt;
Download the completenoobs container image, mediawiki with the completenoobs xml installed.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker pull completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Run container===&lt;br /&gt;
&lt;br /&gt;
* Quick Start&lt;br /&gt;
&amp;lt;code&amp;gt;docker run -d -p 8080:80 completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Quick Start with Persistent Storage&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* NOTE: The above &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; command is for quick testing, if you want to be able to export your wiki&#039;s database to an XML file you can backup and share, please use method in expanding info box below. &lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
If you want to be able to Export Your MediaWiki Database to Dated XML File - use this &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; method:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To export your MediaWiki database to a dated XML file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) and save it to the host’s &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory, run the export script inside the Docker container and use a volume mount to write the file to the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Create Host Directory&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, ensure the &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory exists:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Run Container with Volume Mount&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If your container isn’t already using a volume for &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt;, stop and remove it, then restart with a volume mount:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Run Export Script in Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Access the container’s shell:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Inside the container, run the export script to create a dated XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
DATE=$(date +%Y%m%d)&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:/export/$DATE.xml&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This writes the file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt; in the container, which maps to &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; on the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify the File&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, check for the XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ls ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
You should see a file like &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Notes&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
- If you encounter permission issues, ensure the container’s user has write access to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt;:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
chmod -R 777 /export&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- The script must be run inside the container, as it requires MediaWiki’s environment and database access.&amp;lt;br&amp;gt;&lt;br /&gt;
- If your container uses a different volume setup, adjust the mount point accordingly.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Import MediaWiki XML File from Host Directory:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To import a &amp;lt;code&amp;gt;wiki.xml&amp;lt;/code&amp;gt; file from the host’s &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory into your MediaWiki instance running in a Docker container, use the &amp;lt;code&amp;gt;importDump.php&amp;lt;/code&amp;gt; script inside the container. The &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory is mounted as &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt; in the container, allowing the container to read the file.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Place the XML File&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, move or copy the &amp;lt;code&amp;gt;wiki.xml&amp;lt;/code&amp;gt; file to &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt;:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mv ~/Downloads/wiki.xml ~/wiki-container/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Verify the file is present:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ls ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Access the Container’s Shell&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Enter the container’s shell:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Run the Import Script&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Inside the container, import the &amp;lt;code&amp;gt;wiki.xml&amp;lt;/code&amp;gt; file:&amp;lt;br&amp;gt;&lt;br /&gt;
Can check file is present in container with &amp;lt;code&amp;gt;ls /export/&amp;lt;/code&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php importDump.php /export/wiki.xml&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Note: This imports the XML content into the MediaWiki database. For large files, this may take time.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Rebuild Wiki Indexes (Optional but Recommended)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Rebuild the wiki’s indexes to ensure imported content is accessible:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php rebuildall.php&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 5: Exit the Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Exit the container’s shell:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 6: Verify the Import&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Visit your wiki (e.g., &amp;lt;code&amp;gt;http://localhost:8080&amp;lt;/code&amp;gt;) to check if the imported pages appear. If issues arise, check the container logs:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Notes&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
- Ensure the container has read access to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt;. Fix permissions if needed:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
chmod -R 777 /export&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- If you want to overwrite existing pages, use the &amp;lt;code&amp;gt;--no-updates&amp;lt;/code&amp;gt; flag with &amp;lt;code&amp;gt;importDump.php&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
- If the container wasn’t started with &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; mounted as &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt;, restart it with:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 -v ~/wiki-container:/export --name completenoobs_wiki completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Now visit http://localhost:8080 on your browser&lt;br /&gt;
&lt;br /&gt;
* Due to (unknown) bug you might need to update the xml to download missing pages:&lt;br /&gt;
&amp;lt;code&amp;gt;docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Change Admin Password:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
By default, the admin user&#039;s password is &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;. It&#039;s highly recommended to change this immediately. You can do this either through the wiki&#039;s web interface or directly in the Docker terminal.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 1: Change Password via Web Interface&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
This is the easiest method. You can change your password directly from the wiki itself.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. Log in to your wiki with the default credentials: &amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
2. Once logged in, click your username (&amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt;) in the top-right corner of the page.&amp;lt;br&amp;gt;&lt;br /&gt;
3. From the drop-down menu, select &amp;lt;b&amp;gt;Preferences&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
4. On the Preferences page, go to the &amp;lt;b&amp;gt;Password&amp;lt;/b&amp;gt; tab.&amp;lt;br&amp;gt;&lt;br /&gt;
5. Enter the current password (&amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;), then enter your new password twice.&amp;lt;br&amp;gt;&lt;br /&gt;
6. Click &amp;lt;b&amp;gt;Change password&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Your password is now changed, and you will need to use the new one for future logins.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 2: Change Password via Terminal (No-Email Reset)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you have forgotten the password or prefer to use the command line, you can reset it directly inside the Docker container using a MediaWiki maintenance script.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. &amp;lt;b&amp;gt;Access the container&#039;s shell&amp;lt;/b&amp;gt; with the following command from your host machine:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
2. Once inside the container, use the &amp;lt;code&amp;gt;changePassword.php&amp;lt;/code&amp;gt; maintenance script to change the password. This is the modern, recommended way to run MediaWiki maintenance scripts.&amp;lt;br&amp;gt;&lt;br /&gt;
* Change &amp;lt;b&amp;gt;NEWPASSWORD&amp;lt;/b&amp;gt; to your new password&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
3. Type &amp;lt;code&amp;gt;exit&amp;lt;/code&amp;gt; to leave the container&#039;s shell.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The admin password has now been reset. You can log in to your wiki with the new password.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Remove container===&lt;br /&gt;
&lt;br /&gt;
To completely remove the &amp;lt;code&amp;gt;completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; container and image from your Ubuntu 24.04 system, follow these steps. You can also remove associated persistent storage volumes if they were created.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Stop and Remove the Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If the container is running, stop it and then remove it.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Alternatively, stop and remove in one command:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker rm -f completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Remove the Docker Image&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Remove the &amp;lt;code&amp;gt;completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; image.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker rmi completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Note: If the image is in use by other containers, remove those containers first or use &amp;lt;code&amp;gt;docker rmi -f completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; to force removal (use with caution).&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Remove Persistent Storage Volumes (Optional)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you used persistent storage, remove the associated volumes to free up space.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker volume rm completenoobs_mysql completenoobs_images&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Note: Ensure no other containers are using these volumes, as this will delete all stored data.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify Removal&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Check that the container, image, and volumes are removed.&amp;lt;br&amp;gt;&lt;br /&gt;
- List all containers (including stopped ones):&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker ps -a&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- List all images:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker images&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- List all volumes:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker volume ls&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
If any items remain, repeat the relevant removal commands or check for dependencies.&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Creation&amp;diff=665</id>
		<title>CompleteNoobs Docker Image Creation</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Creation&amp;diff=665"/>
		<updated>2025-09-01T20:52:16Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* 3.2: Run the Container */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Complete Noobs Docker Wiki Tutorial =&lt;br /&gt;
* [[Docker_Install_Guide| Docker install guide]]&lt;br /&gt;
==errors==&lt;br /&gt;
* This mainly works - just need to fix the extensions popular pages and contrubtion scores&lt;br /&gt;
* The XML updater requires more work - currently idea placeholder&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
* Ubuntu 24.04&lt;br /&gt;
* Docker installed and running&lt;br /&gt;
* Your user in docker group: &amp;lt;code&amp;gt;sudo usermod -aG docker $USER&amp;lt;/code&amp;gt; (then logout/login)&lt;br /&gt;
&lt;br /&gt;
== Step 2: Create All Files ==&lt;br /&gt;
&lt;br /&gt;
=== 2.1: Dockerfile ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano Dockerfile&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Copy this exactly:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;dockerfile&amp;quot;&amp;gt;&lt;br /&gt;
FROM mediawiki:1.44&lt;br /&gt;
# Mediawiki 1.44 used over latest because can confirm extensions youtube and pagenotice works&lt;br /&gt;
# Install dependencies&lt;br /&gt;
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \&lt;br /&gt;
    mariadb-server \&lt;br /&gt;
    python3 \&lt;br /&gt;
    python3-requests \&lt;br /&gt;
    python3-bs4 \&lt;br /&gt;
    python3-pygments \&lt;br /&gt;
    curl \&lt;br /&gt;
    wget \&lt;br /&gt;
    unzip \&lt;br /&gt;
    nano \&lt;br /&gt;
    git \&lt;br /&gt;
    &amp;amp;&amp;amp; apt-get clean&lt;br /&gt;
&lt;br /&gt;
# Copy scripts&lt;br /&gt;
COPY download_latest_xml.py /usr/src/download_latest_xml.py&lt;br /&gt;
COPY setup_wiki.sh /usr/src/setup_wiki.sh&lt;br /&gt;
COPY update_xml.sh /usr/src/update_xml.sh&lt;br /&gt;
COPY entrypoint.sh /entrypoint.sh&lt;br /&gt;
&lt;br /&gt;
# Make executable&lt;br /&gt;
RUN chmod +x /usr/src/setup_wiki.sh /entrypoint.sh /usr/src/update_xml.sh&lt;br /&gt;
&lt;br /&gt;
# Download XML&lt;br /&gt;
RUN python3 /usr/src/download_latest_xml.py&lt;br /&gt;
&lt;br /&gt;
# Setup wiki&lt;br /&gt;
RUN /usr/src/setup_wiki.sh&lt;br /&gt;
&lt;br /&gt;
EXPOSE 80&lt;br /&gt;
VOLUME /var/lib/mysql&lt;br /&gt;
VOLUME /var/www/html/images&lt;br /&gt;
&lt;br /&gt;
ENTRYPOINT [&amp;quot;/entrypoint.sh&amp;quot;]&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.2: XML Download Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano download_latest_xml.py&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
import os&lt;br /&gt;
import requests&lt;br /&gt;
from bs4 import BeautifulSoup&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
BASE_URL = &amp;quot;https://xml.completenoobs.com/xmlDumps/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
def parse_date_from_dump(dump_name):&lt;br /&gt;
    match = re.match(r&#039;(\d{2})_(\d{2})_(\d{2})\.Noobs&#039;, dump_name)&lt;br /&gt;
    if match:&lt;br /&gt;
        day, month, year = match.groups()&lt;br /&gt;
        year_int = int(year)&lt;br /&gt;
        full_year = 2000 + year_int if year_int &amp;lt;= 49 else 1900 + year_int&lt;br /&gt;
        return (full_year, int(month), int(day))&lt;br /&gt;
    return (0, 0, 0)&lt;br /&gt;
&lt;br /&gt;
def get_available_dumps():&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(BASE_URL, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        dumps = [link.get(&#039;href&#039;).rstrip(&#039;/&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if re.match(r&#039;\d{2}_\d{2}_\d{2}\.Noobs/$&#039;, link.get(&#039;href&#039;, &#039;&#039;))]&lt;br /&gt;
        return sorted(dumps, key=parse_date_from_dump, reverse=True)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error fetching dumps: {e}&amp;quot;)&lt;br /&gt;
        return []&lt;br /&gt;
&lt;br /&gt;
def get_dump_files(dump):&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(f&amp;quot;{BASE_URL}{dump}/&amp;quot;, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        files = [link.get(&#039;href&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if link.get(&#039;href&#039;, &#039;&#039;).endswith(&#039;.xml&#039;)]&lt;br /&gt;
        return sorted(files, reverse=True)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error fetching dump files: {e}&amp;quot;)&lt;br /&gt;
        return []&lt;br /&gt;
&lt;br /&gt;
def download_file(url, filename):&lt;br /&gt;
    try:&lt;br /&gt;
        print(f&amp;quot;Downloading {filename}...&amp;quot;)&lt;br /&gt;
        response = requests.get(url, stream=True, timeout=60)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        &lt;br /&gt;
        total_size = int(response.headers.get(&#039;content-length&#039;, 0))&lt;br /&gt;
        downloaded = 0&lt;br /&gt;
        &lt;br /&gt;
        with open(filename, &#039;wb&#039;) as f:&lt;br /&gt;
            for chunk in response.iter_content(chunk_size=8192):&lt;br /&gt;
                if chunk:&lt;br /&gt;
                    f.write(chunk)&lt;br /&gt;
                    downloaded += len(chunk)&lt;br /&gt;
                    if total_size &amp;gt; 0:&lt;br /&gt;
                        progress = (downloaded / total_size) * 100&lt;br /&gt;
                        print(f&amp;quot;\rProgress: {progress:.1f}%&amp;quot;, end=&#039;&#039;, flush=True)&lt;br /&gt;
        print()&lt;br /&gt;
        return True&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error downloading {filename}: {e}&amp;quot;)&lt;br /&gt;
        return False&lt;br /&gt;
&lt;br /&gt;
def main():&lt;br /&gt;
    print(&amp;quot;Fetching available XML dumps...&amp;quot;)&lt;br /&gt;
    dumps = get_available_dumps()&lt;br /&gt;
    &lt;br /&gt;
    if not dumps:&lt;br /&gt;
        print(&amp;quot;No dumps found!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
    &lt;br /&gt;
    newest_dump = dumps[0]&lt;br /&gt;
    print(f&amp;quot;Latest dump: {newest_dump}&amp;quot;)&lt;br /&gt;
    &lt;br /&gt;
    files = get_dump_files(newest_dump)&lt;br /&gt;
    if not files:&lt;br /&gt;
        print(&amp;quot;No XML files found in latest dump!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
    &lt;br /&gt;
    newest_xml = files[0]&lt;br /&gt;
    xml_url = f&amp;quot;{BASE_URL}{newest_dump}/{newest_xml}&amp;quot;&lt;br /&gt;
    local_filename = &amp;quot;/tmp/completenoobs_dump.xml&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if download_file(xml_url, local_filename):&lt;br /&gt;
        print(f&amp;quot;Successfully downloaded {newest_xml}&amp;quot;)&lt;br /&gt;
        with open(&amp;quot;/tmp/dump_info.txt&amp;quot;, &amp;quot;w&amp;quot;) as f:&lt;br /&gt;
            f.write(f&amp;quot;{newest_dump}/{newest_xml}&amp;quot;)&lt;br /&gt;
    else:&lt;br /&gt;
        print(&amp;quot;Failed to download XML dump!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
&lt;br /&gt;
if __name__ == &amp;quot;__main__&amp;quot;:&lt;br /&gt;
    main()&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.3: Main Setup Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano setup_wiki.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Setting up CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Initialize MariaDB&lt;br /&gt;
if [ ! -d &amp;quot;/var/lib/mysql/mysql&amp;quot; ]; then&lt;br /&gt;
    mysql_install_db --user=mysql --datadir=/var/lib/mysql&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 2&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
# Setup database&lt;br /&gt;
mysql -e &amp;quot;CREATE DATABASE IF NOT EXISTS completenoobs_wiki CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;127.0.0.1&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;127.0.0.1&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;localhost&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;localhost&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;FLUSH PRIVILEGES;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Install MediaWiki&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
php maintenance/install.php \&lt;br /&gt;
    --dbtype=mysql \&lt;br /&gt;
    --dbserver=127.0.0.1 \&lt;br /&gt;
    --dbname=completenoobs_wiki \&lt;br /&gt;
    --dbuser=wikiuser \&lt;br /&gt;
    --dbpass=wikipass \&lt;br /&gt;
    --server=&amp;quot;http://localhost:8080&amp;quot; \&lt;br /&gt;
    --scriptpath=&amp;quot;&amp;quot; \&lt;br /&gt;
    --lang=en \&lt;br /&gt;
    --pass=AdminPass123! \&lt;br /&gt;
    &amp;quot;CompleteNoobs Wiki&amp;quot; \&lt;br /&gt;
    &amp;quot;admin&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Download and install extensions&lt;br /&gt;
cd extensions/&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/PageNotice --branch REL1_44 || echo &amp;quot;PageNotice download failed, continuing...&amp;quot;&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/YouTube --branch REL1_44 || echo &amp;quot;YouTube download failed, continuing...&amp;quot;&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
&lt;br /&gt;
# Configure LocalSettings.php&lt;br /&gt;
cat &amp;gt;&amp;gt; LocalSettings.php &amp;lt;&amp;lt; &#039;EOF&#039;&lt;br /&gt;
# Basic settings&lt;br /&gt;
$wgEnableUploads = true;&lt;br /&gt;
$wgUseImageMagick = true;&lt;br /&gt;
$wgImageMagickConvertCommand = &amp;quot;/usr/bin/convert&amp;quot;;&lt;br /&gt;
$wgDefaultSkin = &amp;quot;vector-2022&amp;quot;;&lt;br /&gt;
$wgAllowExternalImages = true;&lt;br /&gt;
&lt;br /&gt;
# Debug (can be removed later)&lt;br /&gt;
$wgShowExceptionDetails = true;&lt;br /&gt;
$wgDebugLogFile = &amp;quot;/tmp/mediawiki-debug.log&amp;quot;;&lt;br /&gt;
&lt;br /&gt;
# PageNotice extension (if available)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/PageNotice/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;PageNotice&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# YouTube extension (if available)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/YouTube/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;YouTube&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# SyntaxHighlight (usually bundled)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/SyntaxHighlight_GeSHi/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;SyntaxHighlight_GeSHi&#039; );&lt;br /&gt;
    $wgPygmentizePath = &#039;/usr/bin/pygmentize&#039;;&lt;br /&gt;
}&lt;br /&gt;
EOF&lt;br /&gt;
&lt;br /&gt;
# Import XML dump if available&lt;br /&gt;
if [ -f &amp;quot;/tmp/completenoobs_dump.xml&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Importing XML dump...&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if php maintenance/importDump.php --uploads &amp;lt; /tmp/completenoobs_dump.xml; then&lt;br /&gt;
        echo &amp;quot;XML import completed!&amp;quot;&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;XML import had warnings&amp;quot;&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Basic maintenance&lt;br /&gt;
    php maintenance/update.php --quick || echo &amp;quot;Update completed with warnings&amp;quot;&lt;br /&gt;
    php maintenance/rebuildrecentchanges.php || echo &amp;quot;RecentChanges rebuilt with warnings&amp;quot;&lt;br /&gt;
    php maintenance/initSiteStats.php || echo &amp;quot;SiteStats initialized with warnings&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if [ -f &amp;quot;/tmp/dump_info.txt&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;Import: $(cat /tmp/dump_info.txt)&amp;quot; &amp;gt; /var/www/html/.last_import&lt;br /&gt;
        echo &amp;quot;Date: $(date)&amp;quot; &amp;gt;&amp;gt; /var/www/html/.last_import&lt;br /&gt;
    fi&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;No XML dump found - starting with empty wiki&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
# Copy update script to accessible location&lt;br /&gt;
cp /usr/src/update_xml.sh /var/www/html/update_xml.sh&lt;br /&gt;
chmod +x /var/www/html/update_xml.sh&lt;br /&gt;
&lt;br /&gt;
# Create user-friendly update wrapper&lt;br /&gt;
cat &amp;gt; /var/www/html/check_updates.sh &amp;lt;&amp;lt; &#039;UPDATE_WRAPPER_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
echo &amp;quot;=== CompleteNoobs Wiki Update Checker ===&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;This tool checks for new content from the CompleteNoobs XML repository&amp;quot;&lt;br /&gt;
echo &amp;quot;and imports ONLY new pages, preserving all your local edits.&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
/var/www/html/update_xml.sh&lt;br /&gt;
UPDATE_WRAPPER_EOF&lt;br /&gt;
chmod +x /var/www/html/check_updates.sh&lt;br /&gt;
&lt;br /&gt;
# Create simple status script&lt;br /&gt;
cat &amp;gt; /var/www/html/check_status.sh &amp;lt;&amp;lt; &#039;STATUS_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
echo &amp;quot;=== Wiki Status ===&amp;quot;&lt;br /&gt;
echo &amp;quot;Pages: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;Users: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM user WHERE user_id &amp;gt; 0;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Extensions ===&amp;quot;&lt;br /&gt;
if [ -d &amp;quot;extensions/PageNotice&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;PageNotice: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;PageNotice: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/YouTube&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;YouTube: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;YouTube: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/ContributionScores&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;ContributionScores: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;ContributionScores: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Update System ===&amp;quot;&lt;br /&gt;
if [ -f &amp;quot;.last_import&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Current version: $(grep &#039;Import:&#039; .last_import | cut -d&#039; &#039; -f2)&amp;quot;&lt;br /&gt;
    echo &amp;quot;Import date: $(grep &#039;Date:&#039; .last_import | cut -d&#039; &#039; -f2-)&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;No version info available&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;To check for updates: docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;quot;&lt;br /&gt;
STATUS_EOF&lt;br /&gt;
chmod +x /var/www/html/check_status.sh&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Setup completed!&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;Update scripts installed:&amp;quot;&lt;br /&gt;
echo &amp;quot;- /var/www/html/check_updates.sh (user-friendly)&amp;quot;&lt;br /&gt;
echo &amp;quot;- /var/www/html/update_xml.sh (direct)&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Final counts&lt;br /&gt;
PAGES=$(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;0&amp;quot;)&lt;br /&gt;
echo &amp;quot;Pages imported: $PAGES&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb stop&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.4: XML Update Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano update_xml.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;=== CompleteNoobs Wiki XML Updater ===&amp;quot;&lt;br /&gt;
echo &amp;quot;This will check for new and updated pages&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Function to check if running in container&lt;br /&gt;
check_environment() {&lt;br /&gt;
    if [ ! -f &amp;quot;/var/www/html/LocalSettings.php&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;Error: This script must be run inside the wiki container&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to get current XML version&lt;br /&gt;
get_current_version() {&lt;br /&gt;
    if [ -f &amp;quot;/var/www/html/.last_import&amp;quot; ]; then&lt;br /&gt;
        grep &amp;quot;Import:&amp;quot; /var/www/html/.last_import | cut -d&#039; &#039; -f2&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;none&amp;quot;&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to check for updates&lt;br /&gt;
check_for_updates() {&lt;br /&gt;
    python3 - &amp;lt;&amp;lt; &#039;PYTHON_EOF&#039;&lt;br /&gt;
import requests&lt;br /&gt;
from bs4 import BeautifulSoup&lt;br /&gt;
import re&lt;br /&gt;
import sys&lt;br /&gt;
&lt;br /&gt;
BASE_URL = &amp;quot;https://xml.completenoobs.com/xmlDumps/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
def parse_date_from_dump(dump_name):&lt;br /&gt;
    match = re.match(r&#039;(\d{2})_(\d{2})_(\d{2})\.Noobs&#039;, dump_name)&lt;br /&gt;
    if match:&lt;br /&gt;
        day, month, year = match.groups()&lt;br /&gt;
        year_int = int(year)&lt;br /&gt;
        full_year = 2000 + year_int if year_int &amp;lt;= 49 else 1900 + year_int&lt;br /&gt;
        return (full_year, int(month), int(day))&lt;br /&gt;
    return (0, 0, 0)&lt;br /&gt;
&lt;br /&gt;
def get_latest_dump():&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(BASE_URL, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        dumps = [link.get(&#039;href&#039;).rstrip(&#039;/&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if re.match(r&#039;\d{2}_\d{2}_\d{2}\.Noobs/$&#039;, link.get(&#039;href&#039;, &#039;&#039;))]&lt;br /&gt;
        if dumps:&lt;br /&gt;
            latest = sorted(dumps, key=parse_date_from_dump, reverse=True)[0]&lt;br /&gt;
            &lt;br /&gt;
            # Get XML files from latest dump&lt;br /&gt;
            response = requests.get(f&amp;quot;{BASE_URL}{latest}/&amp;quot;, timeout=30)&lt;br /&gt;
            response.raise_for_status()&lt;br /&gt;
            soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
            files = [link.get(&#039;href&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                    if link.get(&#039;href&#039;, &#039;&#039;).endswith(&#039;.xml&#039;)]&lt;br /&gt;
            &lt;br /&gt;
            if files:&lt;br /&gt;
                newest_xml = sorted(files, reverse=True)[0]&lt;br /&gt;
                print(f&amp;quot;{latest}/{newest_xml}&amp;quot;)&lt;br /&gt;
                sys.exit(0)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;ERROR: {e}&amp;quot;, file=sys.stderr)&lt;br /&gt;
        sys.exit(1)&lt;br /&gt;
    &lt;br /&gt;
    print(&amp;quot;ERROR: No dumps found&amp;quot;, file=sys.stderr)&lt;br /&gt;
    sys.exit(1)&lt;br /&gt;
&lt;br /&gt;
get_latest_dump()&lt;br /&gt;
PYTHON_EOF&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to download new XML&lt;br /&gt;
download_xml() {&lt;br /&gt;
    local dump_info=&amp;quot;$1&amp;quot;&lt;br /&gt;
    local dump_dir=$(echo &amp;quot;$dump_info&amp;quot; | cut -d&#039;/&#039; -f1)&lt;br /&gt;
    local xml_file=$(echo &amp;quot;$dump_info&amp;quot; | cut -d&#039;/&#039; -f2)&lt;br /&gt;
    local url=&amp;quot;https://xml.completenoobs.com/xmlDumps/${dump_info}&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Downloading: $xml_file&amp;quot;&lt;br /&gt;
    echo &amp;quot;From: $dump_dir&amp;quot;&lt;br /&gt;
    echo &amp;quot;URL: $url&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if wget -O /tmp/new_dump.xml &amp;quot;$url&amp;quot; --progress=bar:force 2&amp;gt;&amp;amp;1; then&lt;br /&gt;
        echo &amp;quot;Download successful!&amp;quot;&lt;br /&gt;
        return 0&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;Download failed!&amp;quot;&lt;br /&gt;
        return 1&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to backup current database&lt;br /&gt;
backup_database() {&lt;br /&gt;
    echo &amp;quot;Creating database backup...&amp;quot;&lt;br /&gt;
    TIMESTAMP=$(date +%Y%m%d_%H%M%S)&lt;br /&gt;
    mysqldump --user=wikiuser --password=wikipass completenoobs_wiki &amp;gt; /tmp/wiki_backup_${TIMESTAMP}.sql&lt;br /&gt;
    echo &amp;quot;Backup created: /tmp/wiki_backup_${TIMESTAMP}.sql&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to analyze and import changes&lt;br /&gt;
analyze_and_import() {&lt;br /&gt;
    echo &amp;quot;Analyzing differences between XML and local wiki...&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Create analysis and import script for MediaWiki 1.44+&lt;br /&gt;
    cat &amp;gt; /tmp/analyze_import.php &amp;lt;&amp;lt; &#039;PHP_EOF&#039;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
require_once &#039;/var/www/html/maintenance/Maintenance.php&#039;;&lt;br /&gt;
&lt;br /&gt;
class AnalyzeAndImport extends Maintenance {&lt;br /&gt;
    private $db;&lt;br /&gt;
    private $new_pages = [];&lt;br /&gt;
    private $changed_pages = [];&lt;br /&gt;
    private $unchanged_pages = [];&lt;br /&gt;
    &lt;br /&gt;
    public function __construct() {&lt;br /&gt;
        parent::__construct();&lt;br /&gt;
        $this-&amp;gt;addDescription(&#039;Analyze and selectively import from XML dump&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    public function execute() {&lt;br /&gt;
        $this-&amp;gt;db = new mysqli(&#039;127.0.0.1&#039;, &#039;wikiuser&#039;, &#039;wikipass&#039;, &#039;completenoobs_wiki&#039;);&lt;br /&gt;
        &lt;br /&gt;
        // MediaWiki 1.35+ uses slots and content tables&lt;br /&gt;
        // Get existing pages with their content&lt;br /&gt;
        $query = &amp;quot;&lt;br /&gt;
            SELECT p.page_title, p.page_id, c.content_address, c.content_sha1&lt;br /&gt;
            FROM page p&lt;br /&gt;
            JOIN revision r ON p.page_latest = r.rev_id&lt;br /&gt;
            JOIN slots s ON r.rev_id = s.slot_revision_id&lt;br /&gt;
            JOIN slot_roles sr ON s.slot_role_id = sr.role_id&lt;br /&gt;
            JOIN content c ON s.slot_content_id = c.content_id&lt;br /&gt;
            WHERE p.page_namespace = 0 AND sr.role_name = &#039;main&#039;&lt;br /&gt;
        &amp;quot;;&lt;br /&gt;
        &lt;br /&gt;
        $result = $this-&amp;gt;db-&amp;gt;query($query);&lt;br /&gt;
        if (!$result) {&lt;br /&gt;
            $this-&amp;gt;error(&amp;quot;Database query failed: &amp;quot; . $this-&amp;gt;db-&amp;gt;error);&lt;br /&gt;
            return;&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $existing = [];&lt;br /&gt;
        while ($row = $result-&amp;gt;fetch_assoc()) {&lt;br /&gt;
            // Get actual text content&lt;br /&gt;
            $text_content = $this-&amp;gt;getTextContent($row[&#039;content_address&#039;]);&lt;br /&gt;
            $existing[$row[&#039;page_title&#039;]] = [&lt;br /&gt;
                &#039;id&#039; =&amp;gt; $row[&#039;page_id&#039;],&lt;br /&gt;
                &#039;content&#039; =&amp;gt; $text_content,&lt;br /&gt;
                &#039;sha1&#039; =&amp;gt; $row[&#039;content_sha1&#039;]&lt;br /&gt;
            ];&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Parse XML and compare&lt;br /&gt;
        $xml = simplexml_load_file(&#039;/tmp/new_dump.xml&#039;);&lt;br /&gt;
        &lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            $title = str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title);&lt;br /&gt;
            $xml_content = (string)$page-&amp;gt;revision-&amp;gt;text;&lt;br /&gt;
            &lt;br /&gt;
            if (!isset($existing[$title])) {&lt;br /&gt;
                // New page&lt;br /&gt;
                $this-&amp;gt;new_pages[$title] = $xml_content;&lt;br /&gt;
            } else {&lt;br /&gt;
                // Compare content using SHA1 for efficiency&lt;br /&gt;
                $xml_sha1 = sha1($xml_content);&lt;br /&gt;
                &lt;br /&gt;
                if ($existing[$title][&#039;sha1&#039;] !== $xml_sha1) {&lt;br /&gt;
                    // Content is different&lt;br /&gt;
                    $this-&amp;gt;changed_pages[$title] = [&lt;br /&gt;
                        &#039;local&#039; =&amp;gt; $existing[$title][&#039;content&#039;],&lt;br /&gt;
                        &#039;xml&#039; =&amp;gt; $xml_content,&lt;br /&gt;
                        &#039;page_id&#039; =&amp;gt; $existing[$title][&#039;id&#039;]&lt;br /&gt;
                    ];&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;unchanged_pages[] = $title;&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Display summary&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;\n=== Update Analysis ===\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;New pages to import: &amp;quot; . count($this-&amp;gt;new_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Changed pages found: &amp;quot; . count($this-&amp;gt;changed_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Unchanged pages: &amp;quot; . count($this-&amp;gt;unchanged_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        &lt;br /&gt;
        // Save analysis for review&lt;br /&gt;
        file_put_contents(&#039;/tmp/update_analysis.json&#039;, json_encode([&lt;br /&gt;
            &#039;new&#039; =&amp;gt; array_keys($this-&amp;gt;new_pages),&lt;br /&gt;
            &#039;changed&#039; =&amp;gt; array_keys($this-&amp;gt;changed_pages),&lt;br /&gt;
            &#039;unchanged&#039; =&amp;gt; $this-&amp;gt;unchanged_pages&lt;br /&gt;
        ], JSON_PRETTY_PRINT));&lt;br /&gt;
        &lt;br /&gt;
        // Show changed pages with preview (limit to first 20 for readability)&lt;br /&gt;
        if (count($this-&amp;gt;changed_pages) &amp;gt; 0) {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Changed Pages ===\n&amp;quot;);&lt;br /&gt;
            $count = 0;&lt;br /&gt;
            $total_changed = count($this-&amp;gt;changed_pages);&lt;br /&gt;
            &lt;br /&gt;
            foreach ($this-&amp;gt;changed_pages as $title =&amp;gt; $data) {&lt;br /&gt;
                $count++;&lt;br /&gt;
                if ($count &amp;lt;= 20) {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;\n$count. $title\n&amp;quot;);&lt;br /&gt;
                    &lt;br /&gt;
                    // Create a simple diff preview (first 300 chars)&lt;br /&gt;
                    $xml_preview = substr($data[&#039;xml&#039;], 0, 100);&lt;br /&gt;
                    &lt;br /&gt;
                    // Save full diff to file&lt;br /&gt;
                    $safe_title = preg_replace(&#039;/[^a-zA-Z0-9_-]/&#039;, &#039;_&#039;, $title);&lt;br /&gt;
                    $diff_file = &amp;quot;/tmp/diff_${safe_title}.txt&amp;quot;;&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;=== FULL DIFF FOR: $title ===\n\n&amp;quot;);&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;--- LOCAL VERSION ---\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, $data[&#039;local&#039;] . &amp;quot;\n\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;--- XML VERSION ---\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, $data[&#039;xml&#039;], FILE_APPEND);&lt;br /&gt;
                    &lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;   Preview: &amp;quot; . $xml_preview . &amp;quot;...\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
            &lt;br /&gt;
            if ($total_changed &amp;gt; 20) {&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;\n... and &amp;quot; . ($total_changed - 20) . &amp;quot; more changed pages.\n&amp;quot;);&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;All diff files saved to /tmp/diff_*.txt\n&amp;quot;);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Interactive selection&lt;br /&gt;
        if (count($this-&amp;gt;new_pages) &amp;gt; 0 || count($this-&amp;gt;changed_pages) &amp;gt; 0) {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Import Options ===\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;1. Import new pages only (preserve all local changes)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;2. Import new pages + update ALL changed pages (overwrites local changes)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;3. Selective import (choose which updates to apply)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;4. Cancel (no changes)\n&amp;quot;);&lt;br /&gt;
            &lt;br /&gt;
            // Save state for import script WITHOUT the XML object&lt;br /&gt;
            $import_data = [&lt;br /&gt;
                &#039;new_pages&#039; =&amp;gt; $this-&amp;gt;new_pages,&lt;br /&gt;
                &#039;changed_pages&#039; =&amp;gt; $this-&amp;gt;changed_pages,&lt;br /&gt;
                &#039;xml_file&#039; =&amp;gt; &#039;/tmp/new_dump.xml&#039;  // Save path instead of object&lt;br /&gt;
            ];&lt;br /&gt;
            &lt;br /&gt;
            file_put_contents(&#039;/tmp/import_data.ser&#039;, serialize($import_data));&lt;br /&gt;
        } else {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\nNo changes detected. Your wiki is up to date!\n&amp;quot;);&lt;br /&gt;
        }&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function getTextContent($address) {&lt;br /&gt;
        // Handle different content storage formats in MW 1.35+&lt;br /&gt;
        if (strpos($address, &#039;tt:&#039;) === 0) {&lt;br /&gt;
            // Text table reference&lt;br /&gt;
            $text_id = substr($address, 3);&lt;br /&gt;
            $result = $this-&amp;gt;db-&amp;gt;query(&amp;quot;SELECT old_text FROM text WHERE old_id = $text_id&amp;quot;);&lt;br /&gt;
            if ($row = $result-&amp;gt;fetch_assoc()) {&lt;br /&gt;
                return $row[&#039;old_text&#039;];&lt;br /&gt;
            }&lt;br /&gt;
        } elseif (strpos($address, &#039;es:&#039;) === 0) {&lt;br /&gt;
            // External storage - would need special handling&lt;br /&gt;
            return &amp;quot;[External storage content]&amp;quot;;&lt;br /&gt;
        }&lt;br /&gt;
        // Direct content&lt;br /&gt;
        return $address;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
$maintClass = AnalyzeAndImport::class;&lt;br /&gt;
require_once RUN_MAINTENANCE_IF_MAIN;&lt;br /&gt;
PHP_EOF&lt;br /&gt;
&lt;br /&gt;
    cd /var/www/html&lt;br /&gt;
    php /tmp/analyze_import.php&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to perform selected import&lt;br /&gt;
perform_import() {&lt;br /&gt;
    local choice=$1&lt;br /&gt;
    &lt;br /&gt;
    cat &amp;gt; /tmp/do_import.php &amp;lt;&amp;lt; &#039;PHP_EOF&#039;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
require_once &#039;/var/www/html/maintenance/Maintenance.php&#039;;&lt;br /&gt;
&lt;br /&gt;
class DoImport extends Maintenance {&lt;br /&gt;
    public function __construct() {&lt;br /&gt;
        parent::__construct();&lt;br /&gt;
        $this-&amp;gt;addOption(&#039;mode&#039;, &#039;Import mode&#039;, true, true);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    public function execute() {&lt;br /&gt;
        $mode = $this-&amp;gt;getOption(&#039;mode&#039;);&lt;br /&gt;
        $data = unserialize(file_get_contents(&#039;/tmp/import_data.ser&#039;));&lt;br /&gt;
        &lt;br /&gt;
        // Load XML file&lt;br /&gt;
        $xml = simplexml_load_file($data[&#039;xml_file&#039;]);&lt;br /&gt;
        &lt;br /&gt;
        $new_imported = 0;&lt;br /&gt;
        $updated = 0;&lt;br /&gt;
        &lt;br /&gt;
        // Import new pages (always for modes 1-3)&lt;br /&gt;
        if ($mode != &#039;4&#039;) {&lt;br /&gt;
            foreach ($data[&#039;new_pages&#039;] as $title =&amp;gt; $content) {&lt;br /&gt;
                $this-&amp;gt;importPage($title, $content, $xml);&lt;br /&gt;
                $new_imported++;&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Imported new page: $title\n&amp;quot;);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Handle changed pages based on mode&lt;br /&gt;
        if ($mode == &#039;2&#039;) {&lt;br /&gt;
            // Update all changed pages&lt;br /&gt;
            foreach ($data[&#039;changed_pages&#039;] as $title =&amp;gt; $info) {&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Updating: $title\n&amp;quot;);&lt;br /&gt;
                if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                    $updated++;&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Updated page: $title\n&amp;quot;);&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Failed to update: $title\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        } elseif ($mode == &#039;3&#039;) {&lt;br /&gt;
            // Selective update&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Selective Import Mode ===\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;For each changed page, choose:\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  y = yes, update this page\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  n = no, keep local version\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  d = show diff file\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  a = update all remaining pages\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  s = skip all remaining pages\n\n&amp;quot;);&lt;br /&gt;
            &lt;br /&gt;
            $update_all = false;&lt;br /&gt;
            $skip_all = false;&lt;br /&gt;
            &lt;br /&gt;
            foreach ($data[&#039;changed_pages&#039;] as $title =&amp;gt; $info) {&lt;br /&gt;
                if ($skip_all) {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                    continue;&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                if ($update_all) {&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                    continue;&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;\nPage: $title\n&amp;quot;);&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Action (y/n/d/a/s): &amp;quot;);&lt;br /&gt;
                $handle = fopen(&amp;quot;php://stdin&amp;quot;, &amp;quot;r&amp;quot;);&lt;br /&gt;
                $line = trim(fgets($handle));&lt;br /&gt;
                &lt;br /&gt;
                while ($line == &#039;d&#039;) {&lt;br /&gt;
                    // Show diff&lt;br /&gt;
                    $safe_title = preg_replace(&#039;/[^a-zA-Z0-9_-]/&#039;, &#039;_&#039;, $title);&lt;br /&gt;
                    $diff_file = &amp;quot;/tmp/diff_${safe_title}.txt&amp;quot;;&lt;br /&gt;
                    if (file_exists($diff_file)) {&lt;br /&gt;
                        system(&amp;quot;head -50 $diff_file&amp;quot;);&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;\n[Showing first 50 lines - full file at $diff_file]\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Action (y/n/a/s): &amp;quot;);&lt;br /&gt;
                    $line = trim(fgets($handle));&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                if ($line == &#039;a&#039;) {&lt;br /&gt;
                    $update_all = true;&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                } elseif ($line == &#039;s&#039;) {&lt;br /&gt;
                    $skip_all = true;&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                } elseif ($line == &#039;y&#039;) {&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Failed to update: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;\n=== Import Complete ===\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;New pages imported: $new_imported\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Pages updated: $updated\n&amp;quot;);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function importPage($title, $content, $xml) {&lt;br /&gt;
        // Create single page XML for import&lt;br /&gt;
        $tempFile = &#039;/tmp/single_page_&#039; . md5($title) . &#039;.xml&#039;;&lt;br /&gt;
        $singlePage = new SimpleXMLElement(&#039;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;&amp;lt;mediawiki&amp;gt;&amp;lt;/mediawiki&amp;gt;&#039;);&lt;br /&gt;
        &lt;br /&gt;
        if ($xml-&amp;gt;siteinfo) {&lt;br /&gt;
            $siteinfo = $singlePage-&amp;gt;addChild(&#039;siteinfo&#039;);&lt;br /&gt;
            foreach ($xml-&amp;gt;siteinfo-&amp;gt;children() as $child) {&lt;br /&gt;
                $siteinfo-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Find and add the page&lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            if (str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title) == $title) {&lt;br /&gt;
                $newPage = $singlePage-&amp;gt;addChild(&#039;page&#039;);&lt;br /&gt;
                foreach ($page-&amp;gt;children() as $child) {&lt;br /&gt;
                    if ($child-&amp;gt;getName() == &#039;revision&#039;) {&lt;br /&gt;
                        $revision = $newPage-&amp;gt;addChild(&#039;revision&#039;);&lt;br /&gt;
                        foreach ($child-&amp;gt;children() as $revChild) {&lt;br /&gt;
                            $revision-&amp;gt;addChild($revChild-&amp;gt;getName(), (string)$revChild);&lt;br /&gt;
                        }&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $newPage-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                break;&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $singlePage-&amp;gt;asXML($tempFile);&lt;br /&gt;
        exec(&amp;quot;php /var/www/html/maintenance/importDump.php &amp;lt; $tempFile 2&amp;gt;&amp;amp;1&amp;quot;);&lt;br /&gt;
        unlink($tempFile);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function reimportPage($title, $xml) {&lt;br /&gt;
        // For updating existing pages, delete then reimport&lt;br /&gt;
        $db = new mysqli(&#039;127.0.0.1&#039;, &#039;wikiuser&#039;, &#039;wikipass&#039;, &#039;completenoobs_wiki&#039;);&lt;br /&gt;
        &lt;br /&gt;
        // Delete the existing page&lt;br /&gt;
        $safe_title = $db-&amp;gt;real_escape_string(str_replace(&#039; &#039;, &#039;_&#039;, $title));&lt;br /&gt;
        $db-&amp;gt;query(&amp;quot;DELETE FROM page WHERE page_title = &#039;$safe_title&#039; AND page_namespace = 0&amp;quot;);&lt;br /&gt;
        &lt;br /&gt;
        $db-&amp;gt;close();&lt;br /&gt;
        &lt;br /&gt;
        // Now import the new version&lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            if (str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title) == $title) {&lt;br /&gt;
                $tempFile = &#039;/tmp/update_page_&#039; . md5($title) . &#039;.xml&#039;;&lt;br /&gt;
                $singlePage = new SimpleXMLElement(&#039;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;&amp;lt;mediawiki&amp;gt;&amp;lt;/mediawiki&amp;gt;&#039;);&lt;br /&gt;
                &lt;br /&gt;
                if ($xml-&amp;gt;siteinfo) {&lt;br /&gt;
                    $siteinfo = $singlePage-&amp;gt;addChild(&#039;siteinfo&#039;);&lt;br /&gt;
                    foreach ($xml-&amp;gt;siteinfo-&amp;gt;children() as $child) {&lt;br /&gt;
                        $siteinfo-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $newPage = $singlePage-&amp;gt;addChild(&#039;page&#039;);&lt;br /&gt;
                foreach ($page-&amp;gt;children() as $child) {&lt;br /&gt;
                    if ($child-&amp;gt;getName() == &#039;revision&#039;) {&lt;br /&gt;
                        $revision = $newPage-&amp;gt;addChild(&#039;revision&#039;);&lt;br /&gt;
                        foreach ($child-&amp;gt;children() as $revChild) {&lt;br /&gt;
                            $revision-&amp;gt;addChild($revChild-&amp;gt;getName(), (string)$revChild);&lt;br /&gt;
                        }&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $newPage-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $singlePage-&amp;gt;asXML($tempFile);&lt;br /&gt;
                $result = exec(&amp;quot;php /var/www/html/maintenance/importDump.php &amp;lt; $tempFile 2&amp;gt;&amp;amp;1&amp;quot;, $output, $return);&lt;br /&gt;
                unlink($tempFile);&lt;br /&gt;
                &lt;br /&gt;
                return ($return === 0);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        return false;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
$maintClass = DoImport::class;&lt;br /&gt;
require_once RUN_MAINTENANCE_IF_MAIN;&lt;br /&gt;
PHP_EOF&lt;br /&gt;
&lt;br /&gt;
    cd /var/www/html&lt;br /&gt;
    php /tmp/do_import.php --mode=&amp;quot;$choice&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Main execution&lt;br /&gt;
main() {&lt;br /&gt;
    check_environment&lt;br /&gt;
    &lt;br /&gt;
    # Start MariaDB if not running&lt;br /&gt;
    service mariadb status &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 || service mariadb start&lt;br /&gt;
    &lt;br /&gt;
    # Wait for MariaDB&lt;br /&gt;
    for i in {1..30}; do&lt;br /&gt;
        if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
            break&lt;br /&gt;
        fi&lt;br /&gt;
        sleep 1&lt;br /&gt;
    done&lt;br /&gt;
    &lt;br /&gt;
    CURRENT=$(get_current_version)&lt;br /&gt;
    echo &amp;quot;Current version: $CURRENT&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Checking for updates...&amp;quot;&lt;br /&gt;
    LATEST=$(check_for_updates 2&amp;gt;/dev/null)&lt;br /&gt;
    if [ $? -ne 0 ] || [ -z &amp;quot;$LATEST&amp;quot; ] || [[ &amp;quot;$LATEST&amp;quot; == *&amp;quot;ERROR&amp;quot;* ]]; then&lt;br /&gt;
        echo &amp;quot;Failed to check for updates&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Latest available: $LATEST&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Always proceed to analysis even if versions match&lt;br /&gt;
    # (there might be content updates in the same version)&lt;br /&gt;
    echo &amp;quot;Proceeding with content analysis...&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Backup database&lt;br /&gt;
    backup_database&lt;br /&gt;
    &lt;br /&gt;
    # Download new XML&lt;br /&gt;
    if ! download_xml &amp;quot;$LATEST&amp;quot;; then&lt;br /&gt;
        echo &amp;quot;Failed to download new XML&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Analyze differences&lt;br /&gt;
    analyze_and_import&lt;br /&gt;
    &lt;br /&gt;
    # Check if there are changes to import&lt;br /&gt;
    if [ -f &amp;quot;/tmp/import_data.ser&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;&amp;quot;&lt;br /&gt;
        read -p &amp;quot;Choose option (1-4): &amp;quot; -n 1 -r&lt;br /&gt;
        echo&lt;br /&gt;
        &lt;br /&gt;
        if [[ $REPLY =~ ^[1-4]$ ]]; then&lt;br /&gt;
            if [ &amp;quot;$REPLY&amp;quot; != &amp;quot;4&amp;quot; ]; then&lt;br /&gt;
                perform_import &amp;quot;$REPLY&amp;quot;&lt;br /&gt;
                &lt;br /&gt;
                # Update version info&lt;br /&gt;
                echo &amp;quot;Import: $LATEST&amp;quot; &amp;gt; /var/www/html/.last_import&lt;br /&gt;
                echo &amp;quot;Date: $(date)&amp;quot; &amp;gt;&amp;gt; /var/www/html/.last_import&lt;br /&gt;
                &lt;br /&gt;
                # Rebuild indices&lt;br /&gt;
                echo &amp;quot;Rebuilding indices...&amp;quot;&lt;br /&gt;
                php maintenance/rebuildrecentchanges.php&lt;br /&gt;
                php maintenance/initSiteStats.php&lt;br /&gt;
            else&lt;br /&gt;
                echo &amp;quot;Update cancelled&amp;quot;&lt;br /&gt;
            fi&lt;br /&gt;
        else&lt;br /&gt;
            echo &amp;quot;Invalid option. Update cancelled&amp;quot;&lt;br /&gt;
        fi&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Clean up temp files&lt;br /&gt;
    rm -f /tmp/import_data.ser /tmp/update_analysis.json /tmp/diff_*.txt /tmp/analyze_import.php /tmp/do_import.php 2&amp;gt;/dev/null&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    echo &amp;quot;Done!&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Run main function&lt;br /&gt;
main &amp;quot;$@&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.5: Entrypoint Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano entrypoint.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Starting CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 1&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;CompleteNoobs Wiki ready at: http://localhost:8080&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin login: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Features:&amp;quot;&lt;br /&gt;
echo &amp;quot;- Complete wiki content imported from XML&amp;quot;&lt;br /&gt;
echo &amp;quot;- License notices on all pages (via PageNotice)&amp;quot;&lt;br /&gt;
echo &amp;quot;- SyntaxHighlight for code blocks&amp;quot;&lt;br /&gt;
echo &amp;quot;- YouTube video embedding&amp;quot;&lt;br /&gt;
echo &amp;quot;- Contribution Scores special page&amp;quot;&lt;br /&gt;
echo &amp;quot;- XML update system (preserves local edits)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;To check for updates: docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
apache2-foreground&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 3: Build and Run ==&lt;br /&gt;
&lt;br /&gt;
=== 3.1: Build the Image ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker build -t completenoobs/wiki:latest .&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This will take several minutes.&lt;br /&gt;
&lt;br /&gt;
=== 3.2: Run the Container ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki completenoobs/wiki:latest&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* NOTE: The above &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; command is for quick testing, if you want to be able to export your wiki&#039;s database to an XML file you can backup and share, please use method in expanding info box below. &lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
If you want to be able to Export Your MediaWiki Database to Dated XML File - use this &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; method:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To export your MediaWiki database to a dated XML file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) and save it to the host’s &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory, run the export script inside the Docker container and use a volume mount to write the file to the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Create Host Directory&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, ensure the &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory exists:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Run Container with Volume Mount&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If your container isn’t already using a volume for &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt;, stop and remove it, then restart with a volume mount:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Run Export Script in Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Access the container’s shell:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Inside the container, run the export script to create a dated XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
DATE=$(date +%Y%m%d)&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:/export/$DATE.xml&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This writes the file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt; in the container, which maps to &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; on the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify the File&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, check for the XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ls ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
You should see a file like &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Notes&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
- If you encounter permission issues, ensure the container’s user has write access to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt;:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
chmod -R 777 /export&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- The script must be run inside the container, as it requires MediaWiki’s environment and database access.&amp;lt;br&amp;gt;&lt;br /&gt;
- If your container uses a different volume setup, adjust the mount point accordingly.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 4: Test Everything ==&lt;br /&gt;
&lt;br /&gt;
=== 4.1: Check the Wiki ===&lt;br /&gt;
* Visit: http://localhost:8080&lt;br /&gt;
* You should see the PageNotice at the top&lt;br /&gt;
* Login with: admin / AdminPass123!&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Change Admin Password:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
By default, the admin user&#039;s password is &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;. It&#039;s highly recommended to change this immediately. You can do this either through the wiki&#039;s web interface or directly in the Docker terminal.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 1: Change Password via Web Interface&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
This is the easiest method. You can change your password directly from the wiki itself.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. Log in to your wiki with the default credentials: &amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
2. Once logged in, click your username (&amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt;) in the top-right corner of the page.&amp;lt;br&amp;gt;&lt;br /&gt;
3. From the drop-down menu, select &amp;lt;b&amp;gt;Preferences&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
4. On the Preferences page, go to the &amp;lt;b&amp;gt;Password&amp;lt;/b&amp;gt; tab.&amp;lt;br&amp;gt;&lt;br /&gt;
5. Enter the current password (&amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;), then enter your new password twice.&amp;lt;br&amp;gt;&lt;br /&gt;
6. Click &amp;lt;b&amp;gt;Change password&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Your password is now changed, and you will need to use the new one for future logins.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 2: Change Password via Terminal (No-Email Reset)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you have forgotten the password or prefer to use the command line, you can reset it directly inside the Docker container using a MediaWiki maintenance script.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. &amp;lt;b&amp;gt;Access the container&#039;s shell&amp;lt;/b&amp;gt; with the following command from your host machine:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
2. Once inside the container, use the &amp;lt;code&amp;gt;changePassword.php&amp;lt;/code&amp;gt; maintenance script to change the password. This is the modern, recommended way to run MediaWiki maintenance scripts.&amp;lt;br&amp;gt;&lt;br /&gt;
* Change &amp;lt;b&amp;gt;NEWPASSWORD&amp;lt;/b&amp;gt; to your new password&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
3. Type &amp;lt;code&amp;gt;exit&amp;lt;/code&amp;gt; to leave the container&#039;s shell.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The admin password has now been reset. You can log in to your wiki with the new password.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.2: Test Extensions ===&lt;br /&gt;
* &#039;&#039;&#039;YouTube&#039;&#039;&#039;: Edit any page, add &amp;lt;code&amp;gt;&amp;lt;youtube&amp;gt;N9qYF9DZPdw&amp;lt;/youtube&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;PageNotice&#039;&#039;&#039;: Should already be visible at the top&lt;br /&gt;
* &#039;&#039;&#039;SyntaxHighlight&#039;&#039;&#039;: Add code blocks with &amp;lt;nowiki&amp;gt;&amp;lt;code&amp;gt;&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;code here&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/nowiki&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.3: Check Status ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/check_status.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 5: XML Update Operations ==&lt;br /&gt;
*NOTE: update_xml.sh still needs alot of work, this just idea placeholder for now.&lt;br /&gt;
=== 5.1: Check for Updates (Interactive) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This will:&lt;br /&gt;
* Check the CompleteNoobs XML repository for new dumps&lt;br /&gt;
* Compare with your current version&lt;br /&gt;
* Ask for confirmation before updating&lt;br /&gt;
* Import ONLY new pages (preserves your edits)&lt;br /&gt;
* Create a backup before making changes&lt;br /&gt;
&lt;br /&gt;
=== 5.2: Force Update (Non-Interactive) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki bash -c &amp;quot;echo &#039;y&#039; | /var/www/html/update_xml.sh&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5.3: Manual Update Process ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Enter container&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&lt;br /&gt;
# 2. Check current version&lt;br /&gt;
cat /var/www/html/.last_import&lt;br /&gt;
&lt;br /&gt;
# 3. Run update&lt;br /&gt;
/var/www/html/check_updates.sh&lt;br /&gt;
&lt;br /&gt;
# 4. Exit container&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 6: Troubleshooting Commands ==&lt;br /&gt;
=== Access container shell: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Edit configuration: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki nano /var/www/html/LocalSettings.php&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Check logs: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== View update logs: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki tail -f /tmp/mediawiki-debug.log&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Complete restart: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki completenoobs/wiki:latest&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Expected Results ==&lt;br /&gt;
* Working wiki with imported CompleteNoobs content&lt;br /&gt;
* PageNotice visible at top of all pages&lt;br /&gt;
* All extensions functional&lt;br /&gt;
* Text editors (nano/vim) available in container&lt;br /&gt;
* Utility scripts for maintenance&lt;br /&gt;
* XML update system that preserves local edits&lt;br /&gt;
&lt;br /&gt;
==need to add==&lt;br /&gt;
* way for user to backup there local custom wiki - xml exporter&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Ubuntu2404_Install_Docker_and_Docker_Compose&amp;diff=664</id>
		<title>Ubuntu2404 Install Docker and Docker Compose</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Ubuntu2404_Install_Docker_and_Docker_Compose&amp;diff=664"/>
		<updated>2025-09-01T20:50:32Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Run container */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;=== Preparation ===&lt;br /&gt;
&lt;br /&gt;
Before we begin, make sure you&#039;re logged in with a user account that has sudo privileges.&lt;br /&gt;
&lt;br /&gt;
=== Update System Packages ===&lt;br /&gt;
&lt;br /&gt;
Update your package list to ensure you have the latest versions of packages:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Install Docker Prerequisites ===&lt;br /&gt;
&lt;br /&gt;
Install the necessary packages for Docker setup:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo apt install -y apt-transport-https ca-certificates curl software-properties-common&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Setup Docker Repository ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Add Docker&#039;s Official GPG Key&#039;&#039;&#039;:&lt;br /&gt;
 &lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Add the Docker Repository&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  echo &amp;quot;deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable&amp;quot; | sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Install Docker and Docker Compose ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Update Package List Again&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo apt update&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Install Docker Engine, CLI, Containerd, and Additional Tools&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo apt install -y docker-ce docker-ce-cli containerd.io python3-bs4 python3-requests docker-compose&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;!-- &lt;br /&gt;
* &#039;&#039;&#039;Install Docker Compose&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
Here we&#039;re downloading the latest version of Docker Compose:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo curl -L &amp;quot;https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)&amp;quot; -o /usr/local/bin/docker-compose&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Make the Docker Compose binary executable:&lt;br /&gt;
&lt;br /&gt;
  &amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
  sudo chmod +x /usr/local/bin/docker-compose&lt;br /&gt;
  &amp;lt;/source&amp;gt;&lt;br /&gt;
--&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Verify Installation ===&lt;br /&gt;
&lt;br /&gt;
Check if Docker and Docker Compose are installed correctly:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker --version&lt;br /&gt;
docker-compose --version&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Configure User Permissions ===&lt;br /&gt;
&lt;br /&gt;
To run Docker commands without &amp;lt;code&amp;gt;sudo&amp;lt;/code&amp;gt;, add your user to the &amp;lt;code&amp;gt;docker&amp;lt;/code&amp;gt; group:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo usermod -aG docker $USER&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Note&#039;&#039;&#039;: After adding your user to the docker group, you&#039;ll need to &#039;&#039;&#039;log out and log back in&#039;&#039;&#039; for the changes to take effect.&lt;br /&gt;
If you do not log out and back in, Or you do not add your $USER to the docker group, you will be required to use sudo in some cases. such as ..&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
a way to apply group changes without logging out and back in - tip:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
exec sudo su -l $USER&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This command will replace your current shell with a new login shell for your user, which will have the updated group memberships. Both of these methods will apply the group changes immediately, allowing you to use LXD commands without having to log out and back in. &lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Remember&amp;lt;/b&amp;gt;, these changes only apply to the current terminal session. If you open a new terminal window, you might need to run the command again or log out and back in for the changes to take effect system-wide.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===If Installing Docker messed up your LXC/LXD Networking===&lt;br /&gt;
To resolve networking conflicts between Docker and LXC containers on Ubuntu 24.04, enable IP forwarding on the host system:&lt;br /&gt;
* Open the sysctl configuration file in your preferred editor&lt;br /&gt;
&amp;lt;code&amp;gt;sudo $EDITOR /etc/sysctl.conf&amp;lt;/code&amp;gt;&lt;br /&gt;
* Uncomment or add the following line (around line 28):&lt;br /&gt;
&amp;lt;pre&amp;gt;net.ipv4.ip_forward=1&amp;lt;/pre&amp;gt;&lt;br /&gt;
* Apply the updated configuration to enable IP forwarding:&lt;br /&gt;
&amp;lt;code&amp;gt;sysctl -p&amp;lt;/code&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
* Restart the system to ensure all changes take effect.&lt;br /&gt;
&lt;br /&gt;
This should resolve the networking issue for LXC containers when Docker is installed.&lt;br /&gt;
&lt;br /&gt;
==Install First Container/Image==&lt;br /&gt;
&lt;br /&gt;
Download the completenoobs container image, mediawiki with the completenoobs xml installed.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker pull completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Run container===&lt;br /&gt;
&lt;br /&gt;
* Quick Start&lt;br /&gt;
&amp;lt;code&amp;gt;docker run -d -p 8080:80 completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Quick Start with Persistent Storage&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* NOTE: The above &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; command is for quick testing, if you want to be able to export your wiki&#039;s database to an XML file you can backup and share, please use method in expanding info box below. &lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
If you want to be able to Export Your MediaWiki Database to Dated XML File - use this &amp;lt;code&amp;gt;docker run&amp;lt;/code&amp;gt; method:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To export your MediaWiki database to a dated XML file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) and save it to the host’s &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory, run the export script inside the Docker container and use a volume mount to write the file to the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Create Host Directory&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, ensure the &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; directory exists:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Run Container with Volume Mount&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If your container isn’t already using a volume for &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt;, stop and remove it, then restart with a volume mount:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v ~/wiki-container:/export \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki \&lt;br /&gt;
  completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Run Export Script in Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Access the container’s shell:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Inside the container, run the export script to create a dated XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
DATE=$(date +%Y%m%d)&lt;br /&gt;
php /var/www/html/maintenance/run.php dumpBackup.php --full --output=file:/export/$DATE.xml&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This writes the file (e.g., &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;) to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt; in the container, which maps to &amp;lt;code&amp;gt;~/wiki-container&amp;lt;/code&amp;gt; on the host.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify the File&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
On the host, check for the XML file:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ls ~/wiki-container&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
You should see a file like &amp;lt;code&amp;gt;20250901.xml&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Notes&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
- If you encounter permission issues, ensure the container’s user has write access to &amp;lt;code&amp;gt;/export&amp;lt;/code&amp;gt;:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
chmod -R 777 /export&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- The script must be run inside the container, as it requires MediaWiki’s environment and database access.&amp;lt;br&amp;gt;&lt;br /&gt;
- If your container uses a different volume setup, adjust the mount point accordingly.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
* Now visit http://localhost:8080 on your browser&lt;br /&gt;
&lt;br /&gt;
* Due to (unknown) bug you might need to update the xml to download missing pages:&lt;br /&gt;
&amp;lt;code&amp;gt;docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Change Admin Password:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
By default, the admin user&#039;s password is &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;. It&#039;s highly recommended to change this immediately. You can do this either through the wiki&#039;s web interface or directly in the Docker terminal.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 1: Change Password via Web Interface&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
This is the easiest method. You can change your password directly from the wiki itself.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. Log in to your wiki with the default credentials: &amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
2. Once logged in, click your username (&amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt;) in the top-right corner of the page.&amp;lt;br&amp;gt;&lt;br /&gt;
3. From the drop-down menu, select &amp;lt;b&amp;gt;Preferences&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
4. On the Preferences page, go to the &amp;lt;b&amp;gt;Password&amp;lt;/b&amp;gt; tab.&amp;lt;br&amp;gt;&lt;br /&gt;
5. Enter the current password (&amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;), then enter your new password twice.&amp;lt;br&amp;gt;&lt;br /&gt;
6. Click &amp;lt;b&amp;gt;Change password&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Your password is now changed, and you will need to use the new one for future logins.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 2: Change Password via Terminal (No-Email Reset)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you have forgotten the password or prefer to use the command line, you can reset it directly inside the Docker container using a MediaWiki maintenance script.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. &amp;lt;b&amp;gt;Access the container&#039;s shell&amp;lt;/b&amp;gt; with the following command from your host machine:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
2. Once inside the container, use the &amp;lt;code&amp;gt;changePassword.php&amp;lt;/code&amp;gt; maintenance script to change the password. This is the modern, recommended way to run MediaWiki maintenance scripts.&amp;lt;br&amp;gt;&lt;br /&gt;
* Change &amp;lt;b&amp;gt;NEWPASSWORD&amp;lt;/b&amp;gt; to your new password&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
3. Type &amp;lt;code&amp;gt;exit&amp;lt;/code&amp;gt; to leave the container&#039;s shell.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The admin password has now been reset. You can log in to your wiki with the new password.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Remove container===&lt;br /&gt;
&lt;br /&gt;
To completely remove the &amp;lt;code&amp;gt;completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; container and image from your Ubuntu 24.04 system, follow these steps. You can also remove associated persistent storage volumes if they were created.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 1: Stop and Remove the Container&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If the container is running, stop it and then remove it.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Alternatively, stop and remove in one command:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker rm -f completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 2: Remove the Docker Image&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Remove the &amp;lt;code&amp;gt;completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; image.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker rmi completenoobs/cnoobs-wiki:0.1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Note: If the image is in use by other containers, remove those containers first or use &amp;lt;code&amp;gt;docker rmi -f completenoobs/cnoobs-wiki:0.1&amp;lt;/code&amp;gt; to force removal (use with caution).&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 3: Remove Persistent Storage Volumes (Optional)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you used persistent storage, remove the associated volumes to free up space.&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker volume rm completenoobs_mysql completenoobs_images&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Note: Ensure no other containers are using these volumes, as this will delete all stored data.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Step 4: Verify Removal&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
Check that the container, image, and volumes are removed.&amp;lt;br&amp;gt;&lt;br /&gt;
- List all containers (including stopped ones):&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker ps -a&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- List all images:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker images&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
- List all volumes:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker volume ls&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
If any items remain, repeat the relevant removal commands or check for dependencies.&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=Docker_Mediawiki_Local_Install&amp;diff=663</id>
		<title>Docker Mediawiki Local Install</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=Docker_Mediawiki_Local_Install&amp;diff=663"/>
		<updated>2025-09-01T20:35:47Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Step 2: Create a Docker Compose File */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Setting Up a Plain MediaWiki Instance with Docker on Ubuntu 24.04 =&lt;br /&gt;
&lt;br /&gt;
This guide provides a simple, step-by-step process to set up a basic MediaWiki instance locally using Docker and Docker Compose on Ubuntu 24.04. &lt;br /&gt;
&lt;br /&gt;
It is designed for beginners and assumes you have Docker and Docker Compose installed (see [[Docker_Install_Guide]] for setup details). &lt;br /&gt;
&lt;br /&gt;
The setup includes MediaWiki with a MariaDB database, running in Docker containers.&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
* Ubuntu 24.04&lt;br /&gt;
* Docker and Docker Compose installed&lt;br /&gt;
* Basic familiarity with terminal commands&lt;br /&gt;
* Internet connection for pulling Docker images&lt;br /&gt;
&lt;br /&gt;
== Step 1: Create a Project Directory ==&lt;br /&gt;
Create a dedicated directory to organize your MediaWiki setup files.&lt;br /&gt;
&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;code&amp;gt;mkdir mediawiki-docker&amp;lt;/code&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;code&amp;gt;cd mediawiki-docker&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 2: Create a Docker Compose File ==&lt;br /&gt;
Create a &#039;&#039;&#039;docker-compose.yml&#039;&#039;&#039; file to define the MediaWiki and MariaDB services.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;nano docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
Paste the following configuration into &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
version: &#039;3.8&#039;&lt;br /&gt;
services:&lt;br /&gt;
  mediawiki:&lt;br /&gt;
    image: mediawiki:1.41&lt;br /&gt;
    ports:&lt;br /&gt;
      - &amp;quot;8080:80&amp;quot;&lt;br /&gt;
    depends_on:&lt;br /&gt;
      database:&lt;br /&gt;
        condition: service_healthy&lt;br /&gt;
    volumes:  #  will uncomment line below after init setup of wiki&lt;br /&gt;
#      - ./LocalSettings.php:/var/www/html/LocalSettings.php:ro    &lt;br /&gt;
    environment:&lt;br /&gt;
      - MEDIAWIKI_DB_HOST=database&lt;br /&gt;
      - MEDIAWIKI_DB_USER=wikiuser&lt;br /&gt;
      - MEDIAWIKI_DB_PASSWORD=securepassword&lt;br /&gt;
      - MEDIAWIKI_DB_NAME=mediawiki&lt;br /&gt;
  database:&lt;br /&gt;
    image: mariadb:10.11&lt;br /&gt;
    environment:&lt;br /&gt;
      - MARIADB_ROOT_PASSWORD=securepassword&lt;br /&gt;
      - MARIADB_DATABASE=mediawiki&lt;br /&gt;
      - MARIADB_USER=wikiuser&lt;br /&gt;
      - MARIADB_PASSWORD=securepassword&lt;br /&gt;
      - MARIADB_AUTO_UPGRADE=1&lt;br /&gt;
    volumes:&lt;br /&gt;
      - db_data:/var/lib/mysql&lt;br /&gt;
    healthcheck:&lt;br /&gt;
      test: [&amp;quot;CMD&amp;quot;, &amp;quot;mysqladmin&amp;quot;, &amp;quot;ping&amp;quot;, &amp;quot;-h&amp;quot;, &amp;quot;localhost&amp;quot;]&lt;br /&gt;
      interval: 10s&lt;br /&gt;
      timeout: 5s&lt;br /&gt;
      retries: 5&lt;br /&gt;
    mem_limit: 512m&lt;br /&gt;
volumes:&lt;br /&gt;
  db_data:&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Save and exit (`Ctrl+O`, `Enter`, `Ctrl+X`).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Explanation:&#039;&#039;&#039;&lt;br /&gt;
* The mediawiki service uses MediaWiki 1.41 for stability.&lt;br /&gt;
* `database` service uses the MariaDB image for the database.&lt;br /&gt;
* Port `8080` maps to MediaWiki&#039;s web server port `80`.&lt;br /&gt;
* Environment variables set up the database connection.&lt;br /&gt;
* A volume persists MariaDB data.&lt;br /&gt;
* `LocalSettings.php` will be mounted later after setup.&lt;br /&gt;
&lt;br /&gt;
== Step 3: Start the Containers ==&lt;br /&gt;
Run the following command to start the MediaWiki and MariaDB containers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker-compose up -d&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* `-d` runs containers in the background.&lt;br /&gt;
* This pulls the MediaWiki and MariaDB images and starts the services.&lt;br /&gt;
&lt;br /&gt;
== Step 4: Verify Containers Are Running ==&lt;br /&gt;
Check that both containers are running:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker ps&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You should see two containers: one for `mediawiki` and one for `mariadb`.&lt;br /&gt;
&lt;br /&gt;
== Step 5: Access the MediaWiki Setup Page ==&lt;br /&gt;
Open a web browser and navigate to:&lt;br /&gt;
&lt;br /&gt;
http://localhost:8080&lt;br /&gt;
&lt;br /&gt;
You should see the MediaWiki setup page. If not, ensure containers are running and port `8080` is not blocked.&lt;br /&gt;
&lt;br /&gt;
== Step 6: Complete the MediaWiki Web Installer ==&lt;br /&gt;
Follow the on-screen instructions in the browser:&lt;br /&gt;
* Select your language and click &amp;quot;Continue.&amp;quot;&lt;br /&gt;
* Accept the defaults for database settings, these details can be found in your &#039;&#039;&#039;docker-compose.yml&#039;&#039;&#039; file.&lt;br /&gt;
** host: &#039;&#039;&#039;database&#039;&#039;&#039;  - change from default &#039;&#039;&#039;localhost&#039;&#039;&#039;&lt;br /&gt;
** user: &#039;&#039;&#039;wikiuser&#039;&#039;&#039; - change from default &#039;&#039;&#039;root&#039;&#039;&#039;&lt;br /&gt;
** password: &#039;&#039;&#039;securepassword&#039;&#039;&#039; &lt;br /&gt;
** database name: &#039;&#039;&#039;mediawiki&#039;&#039;&#039; - change from default &#039;&#039;&#039;my_wiki&#039;&#039;&#039;&lt;br /&gt;
* Set up an admin user and password for your wiki.&lt;br /&gt;
* Complete the installation. At the end, MediaWiki generates a &#039;&#039;&#039;LocalSettings.php&#039;&#039;&#039; file.&lt;br /&gt;
&lt;br /&gt;
== Step 7: Download and Place LocalSettings.php ==&lt;br /&gt;
The installer prompts you to download `LocalSettings.php`. Save it to your `mediawiki-docker` directory:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;mv ~/Downloads/LocalSettings.php ~/mediawiki-docker/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Ensure the file is named exactly &#039;&#039;&#039;LocalSettings.php&#039;&#039;&#039;.&amp;lt;br&amp;gt;&lt;br /&gt;
The Docker Compose configuration mounts this file into the MediaWiki container.&lt;br /&gt;
&lt;br /&gt;
== Step 8: Restart Containers ==&lt;br /&gt;
Restart the containers to apply &#039;&#039;&#039;LocalSettings.php&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
* Turn off container&lt;br /&gt;
&amp;lt;code&amp;gt;docker-compose down&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Edit &#039;&#039;&#039;docker-compose.yml&#039;&#039;&#039; and uncomment the lines&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
#    volumes:  #  will uncomment these 2 lines after init setup of wiki&lt;br /&gt;
#      - ./LocalSettings.php:/var/www/html/LocalSettings.php:ro&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
version: &#039;3.8&#039;&lt;br /&gt;
services:&lt;br /&gt;
  mediawiki:&lt;br /&gt;
    image: mediawiki:1.41&lt;br /&gt;
    ports:&lt;br /&gt;
      - &amp;quot;8080:80&amp;quot;&lt;br /&gt;
    depends_on:&lt;br /&gt;
      database:&lt;br /&gt;
        condition: service_healthy&lt;br /&gt;
    volumes:  #  will uncomment these 2 lines after init setup of wiki&lt;br /&gt;
      - ./LocalSettings.php:/var/www/html/LocalSettings.php:ro    &lt;br /&gt;
    environment:&lt;br /&gt;
      - MEDIAWIKI_DB_HOST=database&lt;br /&gt;
      - MEDIAWIKI_DB_USER=wikiuser&lt;br /&gt;
      - MEDIAWIKI_DB_PASSWORD=securepassword&lt;br /&gt;
      - MEDIAWIKI_DB_NAME=mediawiki&lt;br /&gt;
  database:&lt;br /&gt;
    image: mariadb:10.11&lt;br /&gt;
    environment:&lt;br /&gt;
      - MARIADB_ROOT_PASSWORD=securepassword&lt;br /&gt;
      - MARIADB_DATABASE=mediawiki&lt;br /&gt;
      - MARIADB_USER=wikiuser&lt;br /&gt;
      - MARIADB_PASSWORD=securepassword&lt;br /&gt;
      - MARIADB_AUTO_UPGRADE=1&lt;br /&gt;
    volumes:&lt;br /&gt;
      - db_data:/var/lib/mysql&lt;br /&gt;
    healthcheck:&lt;br /&gt;
      test: [&amp;quot;CMD&amp;quot;, &amp;quot;mysqladmin&amp;quot;, &amp;quot;ping&amp;quot;, &amp;quot;-h&amp;quot;, &amp;quot;localhost&amp;quot;]&lt;br /&gt;
      interval: 10s&lt;br /&gt;
      timeout: 5s&lt;br /&gt;
      retries: 5&lt;br /&gt;
    mem_limit: 512m&lt;br /&gt;
volumes:&lt;br /&gt;
  db_data:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* start container&lt;br /&gt;
&amp;lt;code&amp;gt;docker-compose up -d&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 9: Access Your Wiki ==&lt;br /&gt;
Visit `http://localhost:8080` again. You should now see your MediaWiki instance. Log in with the admin credentials you set during installation.&lt;br /&gt;
&lt;br /&gt;
== Step 10: Basic Usage ==&lt;br /&gt;
* Create and edit pages using the MediaWiki interface.&lt;br /&gt;
* Access the wiki at `http://localhost:8080`.&lt;br /&gt;
* Manage users and settings via the admin account.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
== Stopping and Removing Containers ==&lt;br /&gt;
To stop the containers:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker-compose stop&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
To stop and remove containers (data persists in the `db_data` volume):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker-compose down&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
To remove all data (including the database), also delete the volume:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;docker-compose down -v&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Network Configuration ==&lt;br /&gt;
&lt;br /&gt;
By default, your wiki might only be accessible from the host machine where Docker is running, using &amp;lt;code&amp;gt;localhost&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;127.0.0.1&amp;lt;/code&amp;gt;. However, if you want others on your network to access your wiki, you need to make some adjustments.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current Setup:&#039;&#039;&#039; The computer running Docker Compose has an IP address of &amp;lt;code&amp;gt;192.168.0.44&amp;lt;/code&amp;gt;. &lt;br /&gt;
&lt;br /&gt;
* You can find the IP address of your computer running Docker using the command:&lt;br /&gt;
&amp;lt;code&amp;gt;ip addr&amp;lt;/code&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
OutPut from &amp;lt;code&amp;gt;ip addr&amp;lt;/code&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
noob@noob-HP-EliteDesk-800-G1-DM:~$ ip addr&lt;br /&gt;
1: lo: &amp;lt;LOOPBACK,UP,LOWER_UP&amp;gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000&lt;br /&gt;
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00&lt;br /&gt;
    inet 127.0.0.1/8 scope host lo&lt;br /&gt;
       valid_lft forever preferred_lft forever&lt;br /&gt;
    inet6 ::1/128 scope host noprefixroute &lt;br /&gt;
       valid_lft forever preferred_lft forever&lt;br /&gt;
2: eno1: &amp;lt;NO-CARRIER,BROADCAST,MULTICAST,UP&amp;gt; mtu 1500 qdisc fq_codel state DOWN group default qlen 1000&lt;br /&gt;
    link/ether 8c:dc:d4:3d:93:49 brd ff:ff:ff:ff:ff:ff&lt;br /&gt;
    altname enp0s25&lt;br /&gt;
3: wlxe8de27142be2: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&amp;gt; mtu 1500 qdisc noqueue state UP group default qlen 1000&lt;br /&gt;
    link/ether e8:de:27:14:2b:e2 brd ff:ff:ff:ff:ff:ff&lt;br /&gt;
    inet 192.168.0.44/24 brd 192.168.0.255 scope global dynamic noprefixroute wlxe8de27142be2&lt;br /&gt;
       valid_lft 86357sec preferred_lft 86357sec&lt;br /&gt;
    inet6 fe80::afbe:cc73:73a2:fcdf/64 scope link noprefixroute &lt;br /&gt;
       valid_lft forever preferred_lft forever&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
My IP is &amp;lt;code&amp;gt;192.168.0.44&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If someone from another computer on the network tries to visit &amp;lt;code&amp;gt;192.168.0.44:8080&amp;lt;/code&amp;gt;, they might encounter a &amp;quot;cannot connect&amp;quot; error. This happens because MediaWiki, by default, redirects to &amp;lt;code&amp;gt;127.0.0.1:8080&amp;lt;/code&amp;gt;, which is only accessible from the host machine itself. &lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Solution:&#039;&#039;&#039; To allow access from other devices on the same network, you need to:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Edit the &amp;lt;code&amp;gt;LocalSettings.php&amp;lt;/code&amp;gt; File:&#039;&#039;&#039; This isn&#039;t done inside the Docker container but rather in the directory where your &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt; file is located (~/mediawiki-docker/LocalSettings.php). Here, you need to change the server URL configuration.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Modify URL Configuration:&#039;&#039;&#039; On lines 34-35 of &amp;lt;code&amp;gt;LocalSettings.php&amp;lt;/code&amp;gt;, you&#039;ll find the following:&lt;br /&gt;
&lt;br /&gt;
=== Allowing Access from Other Network Devices ===&lt;br /&gt;
Edit &amp;lt;code&amp;gt;LocalSettings.php&amp;lt;/code&amp;gt; in the Docker Compose directory to change the server URL:&lt;br /&gt;
* Default - around line 33&lt;br /&gt;
&amp;lt;source lang=&amp;quot;php&amp;quot;&amp;gt;&lt;br /&gt;
## The protocol and server name to use in fully-qualified URLs&lt;br /&gt;
$wgServer = &#039;http://127.0.0.1:8080&#039;;&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
*Changed&lt;br /&gt;
&amp;lt;source lang=&amp;quot;php&amp;quot;&amp;gt;&lt;br /&gt;
## The protocol and server name to use in fully-qualified URLs&lt;br /&gt;
$wgServer = &#039;http://192.168.0.44:8080&#039;;&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* Restart Docker to apply changes:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;source lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker-compose restart&lt;br /&gt;
&amp;lt;/source&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This allows access to your wiki from other devices on the network using &amp;lt;code&amp;gt;192.168.0.44:8080&amp;lt;/code&amp;gt;.&lt;br /&gt;
This adjustment tells MediaWiki to use the network IP of the host (&amp;lt;code&amp;gt;192.168.0.44&amp;lt;/code&amp;gt;) instead of the local loopback (&amp;lt;code&amp;gt;127.0.0.1&amp;lt;/code&amp;gt;), allowing other devices on the network to access the wiki through &amp;lt;code&amp;gt;192.168.0.44:8080&amp;lt;/code&amp;gt;. Even after this change, &amp;lt;code&amp;gt;localhost:8080&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;127.0.0.1:8080&amp;lt;/code&amp;gt; will still work on the host machine, but now the wiki is also accessible via the network IP from other devices.&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
* &#039;&#039;&#039;Cannot access localhost:8080&#039;&#039;&#039;: Check `docker ps` to ensure containers are running. Verify port `8080` is not used by another service (`sudo netstat -tuln | grep 8080`).&lt;br /&gt;
* &#039;&#039;&#039;Database connection error&#039;&#039;&#039;: Ensure environment variables in `docker-compose.yml` match the installer settings.&lt;br /&gt;
* &#039;&#039;&#039;LocalSettings.php not found&#039;&#039;&#039;: Confirm the file is in the `mediawiki-docker` directory and named correctly.&lt;br /&gt;
&lt;br /&gt;
== Notes ==&lt;br /&gt;
* The database data is stored in a Docker volume (`db_data`) and persists between container restarts.&lt;br /&gt;
* To customize MediaWiki, edit `LocalSettings.php` and restart containers.&lt;br /&gt;
* For production, secure `MYSQL_ROOT_PASSWORD` and `MEDIAWIKI_DB_PASSWORD` with stronger values.&lt;br /&gt;
&lt;br /&gt;
== References ==&lt;br /&gt;
* [[Docker_Install_Guide]]&lt;br /&gt;
* [https://hub.docker.com/_/mediawiki Official MediaWiki Docker Image]&lt;br /&gt;
* [https://hub.docker.com/_/mariadb Official MariaDB Docker Image]&lt;br /&gt;
* [https://www.mediawiki.org/wiki/Manual:Installing_MediaWiki MediaWiki Installation Guide]&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
	<entry>
		<id>https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Creation&amp;diff=662</id>
		<title>CompleteNoobs Docker Image Creation</title>
		<link rel="alternate" type="text/html" href="https://www.completenoobs.com/noobs/index.php?title=CompleteNoobs_Docker_Image_Creation&amp;diff=662"/>
		<updated>2025-09-01T20:20:28Z</updated>

		<summary type="html">&lt;p&gt;AwesomO: /* Expected Results */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= Complete Noobs Docker Wiki Tutorial =&lt;br /&gt;
* [[Docker_Install_Guide| Docker install guide]]&lt;br /&gt;
==errors==&lt;br /&gt;
* This mainly works - just need to fix the extensions popular pages and contrubtion scores&lt;br /&gt;
* The XML updater requires more work - currently idea placeholder&lt;br /&gt;
&lt;br /&gt;
== Prerequisites ==&lt;br /&gt;
* Ubuntu 24.04&lt;br /&gt;
* Docker installed and running&lt;br /&gt;
* Your user in docker group: &amp;lt;code&amp;gt;sudo usermod -aG docker $USER&amp;lt;/code&amp;gt; (then logout/login)&lt;br /&gt;
&lt;br /&gt;
== Step 2: Create All Files ==&lt;br /&gt;
&lt;br /&gt;
=== 2.1: Dockerfile ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano Dockerfile&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Copy this exactly:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;dockerfile&amp;quot;&amp;gt;&lt;br /&gt;
FROM mediawiki:1.44&lt;br /&gt;
# Mediawiki 1.44 used over latest because can confirm extensions youtube and pagenotice works&lt;br /&gt;
# Install dependencies&lt;br /&gt;
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \&lt;br /&gt;
    mariadb-server \&lt;br /&gt;
    python3 \&lt;br /&gt;
    python3-requests \&lt;br /&gt;
    python3-bs4 \&lt;br /&gt;
    python3-pygments \&lt;br /&gt;
    curl \&lt;br /&gt;
    wget \&lt;br /&gt;
    unzip \&lt;br /&gt;
    nano \&lt;br /&gt;
    git \&lt;br /&gt;
    &amp;amp;&amp;amp; apt-get clean&lt;br /&gt;
&lt;br /&gt;
# Copy scripts&lt;br /&gt;
COPY download_latest_xml.py /usr/src/download_latest_xml.py&lt;br /&gt;
COPY setup_wiki.sh /usr/src/setup_wiki.sh&lt;br /&gt;
COPY update_xml.sh /usr/src/update_xml.sh&lt;br /&gt;
COPY entrypoint.sh /entrypoint.sh&lt;br /&gt;
&lt;br /&gt;
# Make executable&lt;br /&gt;
RUN chmod +x /usr/src/setup_wiki.sh /entrypoint.sh /usr/src/update_xml.sh&lt;br /&gt;
&lt;br /&gt;
# Download XML&lt;br /&gt;
RUN python3 /usr/src/download_latest_xml.py&lt;br /&gt;
&lt;br /&gt;
# Setup wiki&lt;br /&gt;
RUN /usr/src/setup_wiki.sh&lt;br /&gt;
&lt;br /&gt;
EXPOSE 80&lt;br /&gt;
VOLUME /var/lib/mysql&lt;br /&gt;
VOLUME /var/www/html/images&lt;br /&gt;
&lt;br /&gt;
ENTRYPOINT [&amp;quot;/entrypoint.sh&amp;quot;]&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.2: XML Download Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano download_latest_xml.py&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;&lt;br /&gt;
#!/usr/bin/env python3&lt;br /&gt;
import os&lt;br /&gt;
import requests&lt;br /&gt;
from bs4 import BeautifulSoup&lt;br /&gt;
import re&lt;br /&gt;
&lt;br /&gt;
BASE_URL = &amp;quot;https://xml.completenoobs.com/xmlDumps/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
def parse_date_from_dump(dump_name):&lt;br /&gt;
    match = re.match(r&#039;(\d{2})_(\d{2})_(\d{2})\.Noobs&#039;, dump_name)&lt;br /&gt;
    if match:&lt;br /&gt;
        day, month, year = match.groups()&lt;br /&gt;
        year_int = int(year)&lt;br /&gt;
        full_year = 2000 + year_int if year_int &amp;lt;= 49 else 1900 + year_int&lt;br /&gt;
        return (full_year, int(month), int(day))&lt;br /&gt;
    return (0, 0, 0)&lt;br /&gt;
&lt;br /&gt;
def get_available_dumps():&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(BASE_URL, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        dumps = [link.get(&#039;href&#039;).rstrip(&#039;/&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if re.match(r&#039;\d{2}_\d{2}_\d{2}\.Noobs/$&#039;, link.get(&#039;href&#039;, &#039;&#039;))]&lt;br /&gt;
        return sorted(dumps, key=parse_date_from_dump, reverse=True)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error fetching dumps: {e}&amp;quot;)&lt;br /&gt;
        return []&lt;br /&gt;
&lt;br /&gt;
def get_dump_files(dump):&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(f&amp;quot;{BASE_URL}{dump}/&amp;quot;, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        files = [link.get(&#039;href&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if link.get(&#039;href&#039;, &#039;&#039;).endswith(&#039;.xml&#039;)]&lt;br /&gt;
        return sorted(files, reverse=True)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error fetching dump files: {e}&amp;quot;)&lt;br /&gt;
        return []&lt;br /&gt;
&lt;br /&gt;
def download_file(url, filename):&lt;br /&gt;
    try:&lt;br /&gt;
        print(f&amp;quot;Downloading {filename}...&amp;quot;)&lt;br /&gt;
        response = requests.get(url, stream=True, timeout=60)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        &lt;br /&gt;
        total_size = int(response.headers.get(&#039;content-length&#039;, 0))&lt;br /&gt;
        downloaded = 0&lt;br /&gt;
        &lt;br /&gt;
        with open(filename, &#039;wb&#039;) as f:&lt;br /&gt;
            for chunk in response.iter_content(chunk_size=8192):&lt;br /&gt;
                if chunk:&lt;br /&gt;
                    f.write(chunk)&lt;br /&gt;
                    downloaded += len(chunk)&lt;br /&gt;
                    if total_size &amp;gt; 0:&lt;br /&gt;
                        progress = (downloaded / total_size) * 100&lt;br /&gt;
                        print(f&amp;quot;\rProgress: {progress:.1f}%&amp;quot;, end=&#039;&#039;, flush=True)&lt;br /&gt;
        print()&lt;br /&gt;
        return True&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;Error downloading {filename}: {e}&amp;quot;)&lt;br /&gt;
        return False&lt;br /&gt;
&lt;br /&gt;
def main():&lt;br /&gt;
    print(&amp;quot;Fetching available XML dumps...&amp;quot;)&lt;br /&gt;
    dumps = get_available_dumps()&lt;br /&gt;
    &lt;br /&gt;
    if not dumps:&lt;br /&gt;
        print(&amp;quot;No dumps found!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
    &lt;br /&gt;
    newest_dump = dumps[0]&lt;br /&gt;
    print(f&amp;quot;Latest dump: {newest_dump}&amp;quot;)&lt;br /&gt;
    &lt;br /&gt;
    files = get_dump_files(newest_dump)&lt;br /&gt;
    if not files:&lt;br /&gt;
        print(&amp;quot;No XML files found in latest dump!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
    &lt;br /&gt;
    newest_xml = files[0]&lt;br /&gt;
    xml_url = f&amp;quot;{BASE_URL}{newest_dump}/{newest_xml}&amp;quot;&lt;br /&gt;
    local_filename = &amp;quot;/tmp/completenoobs_dump.xml&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if download_file(xml_url, local_filename):&lt;br /&gt;
        print(f&amp;quot;Successfully downloaded {newest_xml}&amp;quot;)&lt;br /&gt;
        with open(&amp;quot;/tmp/dump_info.txt&amp;quot;, &amp;quot;w&amp;quot;) as f:&lt;br /&gt;
            f.write(f&amp;quot;{newest_dump}/{newest_xml}&amp;quot;)&lt;br /&gt;
    else:&lt;br /&gt;
        print(&amp;quot;Failed to download XML dump!&amp;quot;)&lt;br /&gt;
        exit(1)&lt;br /&gt;
&lt;br /&gt;
if __name__ == &amp;quot;__main__&amp;quot;:&lt;br /&gt;
    main()&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.3: Main Setup Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano setup_wiki.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Setting up CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Initialize MariaDB&lt;br /&gt;
if [ ! -d &amp;quot;/var/lib/mysql/mysql&amp;quot; ]; then&lt;br /&gt;
    mysql_install_db --user=mysql --datadir=/var/lib/mysql&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 2&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
# Setup database&lt;br /&gt;
mysql -e &amp;quot;CREATE DATABASE IF NOT EXISTS completenoobs_wiki CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;127.0.0.1&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;127.0.0.1&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;CREATE USER IF NOT EXISTS &#039;wikiuser&#039;@&#039;localhost&#039; IDENTIFIED BY &#039;wikipass&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;GRANT ALL PRIVILEGES ON completenoobs_wiki.* TO &#039;wikiuser&#039;@&#039;localhost&#039;;&amp;quot;&lt;br /&gt;
mysql -e &amp;quot;FLUSH PRIVILEGES;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Install MediaWiki&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
php maintenance/install.php \&lt;br /&gt;
    --dbtype=mysql \&lt;br /&gt;
    --dbserver=127.0.0.1 \&lt;br /&gt;
    --dbname=completenoobs_wiki \&lt;br /&gt;
    --dbuser=wikiuser \&lt;br /&gt;
    --dbpass=wikipass \&lt;br /&gt;
    --server=&amp;quot;http://localhost:8080&amp;quot; \&lt;br /&gt;
    --scriptpath=&amp;quot;&amp;quot; \&lt;br /&gt;
    --lang=en \&lt;br /&gt;
    --pass=AdminPass123! \&lt;br /&gt;
    &amp;quot;CompleteNoobs Wiki&amp;quot; \&lt;br /&gt;
    &amp;quot;admin&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Download and install extensions&lt;br /&gt;
cd extensions/&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/PageNotice --branch REL1_44 || echo &amp;quot;PageNotice download failed, continuing...&amp;quot;&lt;br /&gt;
git clone https://gerrit.wikimedia.org/r/mediawiki/extensions/YouTube --branch REL1_44 || echo &amp;quot;YouTube download failed, continuing...&amp;quot;&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
&lt;br /&gt;
# Configure LocalSettings.php&lt;br /&gt;
cat &amp;gt;&amp;gt; LocalSettings.php &amp;lt;&amp;lt; &#039;EOF&#039;&lt;br /&gt;
# Basic settings&lt;br /&gt;
$wgEnableUploads = true;&lt;br /&gt;
$wgUseImageMagick = true;&lt;br /&gt;
$wgImageMagickConvertCommand = &amp;quot;/usr/bin/convert&amp;quot;;&lt;br /&gt;
$wgDefaultSkin = &amp;quot;vector-2022&amp;quot;;&lt;br /&gt;
$wgAllowExternalImages = true;&lt;br /&gt;
&lt;br /&gt;
# Debug (can be removed later)&lt;br /&gt;
$wgShowExceptionDetails = true;&lt;br /&gt;
$wgDebugLogFile = &amp;quot;/tmp/mediawiki-debug.log&amp;quot;;&lt;br /&gt;
&lt;br /&gt;
# PageNotice extension (if available)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/PageNotice/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;PageNotice&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# YouTube extension (if available)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/YouTube/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;YouTube&#039; );&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# SyntaxHighlight (usually bundled)&lt;br /&gt;
if ( file_exists( &amp;quot;$IP/extensions/SyntaxHighlight_GeSHi/extension.json&amp;quot; ) ) {&lt;br /&gt;
    wfLoadExtension( &#039;SyntaxHighlight_GeSHi&#039; );&lt;br /&gt;
    $wgPygmentizePath = &#039;/usr/bin/pygmentize&#039;;&lt;br /&gt;
}&lt;br /&gt;
EOF&lt;br /&gt;
&lt;br /&gt;
# Import XML dump if available&lt;br /&gt;
if [ -f &amp;quot;/tmp/completenoobs_dump.xml&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Importing XML dump...&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if php maintenance/importDump.php --uploads &amp;lt; /tmp/completenoobs_dump.xml; then&lt;br /&gt;
        echo &amp;quot;XML import completed!&amp;quot;&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;XML import had warnings&amp;quot;&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Basic maintenance&lt;br /&gt;
    php maintenance/update.php --quick || echo &amp;quot;Update completed with warnings&amp;quot;&lt;br /&gt;
    php maintenance/rebuildrecentchanges.php || echo &amp;quot;RecentChanges rebuilt with warnings&amp;quot;&lt;br /&gt;
    php maintenance/initSiteStats.php || echo &amp;quot;SiteStats initialized with warnings&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if [ -f &amp;quot;/tmp/dump_info.txt&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;Import: $(cat /tmp/dump_info.txt)&amp;quot; &amp;gt; /var/www/html/.last_import&lt;br /&gt;
        echo &amp;quot;Date: $(date)&amp;quot; &amp;gt;&amp;gt; /var/www/html/.last_import&lt;br /&gt;
    fi&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;No XML dump found - starting with empty wiki&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
&lt;br /&gt;
# Copy update script to accessible location&lt;br /&gt;
cp /usr/src/update_xml.sh /var/www/html/update_xml.sh&lt;br /&gt;
chmod +x /var/www/html/update_xml.sh&lt;br /&gt;
&lt;br /&gt;
# Create user-friendly update wrapper&lt;br /&gt;
cat &amp;gt; /var/www/html/check_updates.sh &amp;lt;&amp;lt; &#039;UPDATE_WRAPPER_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
echo &amp;quot;=== CompleteNoobs Wiki Update Checker ===&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;This tool checks for new content from the CompleteNoobs XML repository&amp;quot;&lt;br /&gt;
echo &amp;quot;and imports ONLY new pages, preserving all your local edits.&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
/var/www/html/update_xml.sh&lt;br /&gt;
UPDATE_WRAPPER_EOF&lt;br /&gt;
chmod +x /var/www/html/check_updates.sh&lt;br /&gt;
&lt;br /&gt;
# Create simple status script&lt;br /&gt;
cat &amp;gt; /var/www/html/check_status.sh &amp;lt;&amp;lt; &#039;STATUS_EOF&#039;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
cd /var/www/html&lt;br /&gt;
echo &amp;quot;=== Wiki Status ===&amp;quot;&lt;br /&gt;
echo &amp;quot;Pages: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;Users: $(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM user WHERE user_id &amp;gt; 0;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;Error&amp;quot;)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Extensions ===&amp;quot;&lt;br /&gt;
if [ -d &amp;quot;extensions/PageNotice&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;PageNotice: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;PageNotice: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/YouTube&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;YouTube: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;YouTube: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
if [ -d &amp;quot;extensions/ContributionScores&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;ContributionScores: Installed&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;ContributionScores: Not installed&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;=== Update System ===&amp;quot;&lt;br /&gt;
if [ -f &amp;quot;.last_import&amp;quot; ]; then&lt;br /&gt;
    echo &amp;quot;Current version: $(grep &#039;Import:&#039; .last_import | cut -d&#039; &#039; -f2)&amp;quot;&lt;br /&gt;
    echo &amp;quot;Import date: $(grep &#039;Date:&#039; .last_import | cut -d&#039; &#039; -f2-)&amp;quot;&lt;br /&gt;
else&lt;br /&gt;
    echo &amp;quot;No version info available&amp;quot;&lt;br /&gt;
fi&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;To check for updates: docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;quot;&lt;br /&gt;
STATUS_EOF&lt;br /&gt;
chmod +x /var/www/html/check_status.sh&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Setup completed!&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;Update scripts installed:&amp;quot;&lt;br /&gt;
echo &amp;quot;- /var/www/html/check_updates.sh (user-friendly)&amp;quot;&lt;br /&gt;
echo &amp;quot;- /var/www/html/update_xml.sh (direct)&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Final counts&lt;br /&gt;
PAGES=$(mysql --user=wikiuser --password=wikipass completenoobs_wiki -e &amp;quot;SELECT COUNT(*) FROM page;&amp;quot; -s -N 2&amp;gt;/dev/null || echo &amp;quot;0&amp;quot;)&lt;br /&gt;
echo &amp;quot;Pages imported: $PAGES&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb stop&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.4: XML Update Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano update_xml.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
set -e&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;=== CompleteNoobs Wiki XML Updater ===&amp;quot;&lt;br /&gt;
echo &amp;quot;This will check for new and updated pages&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Function to check if running in container&lt;br /&gt;
check_environment() {&lt;br /&gt;
    if [ ! -f &amp;quot;/var/www/html/LocalSettings.php&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;Error: This script must be run inside the wiki container&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to get current XML version&lt;br /&gt;
get_current_version() {&lt;br /&gt;
    if [ -f &amp;quot;/var/www/html/.last_import&amp;quot; ]; then&lt;br /&gt;
        grep &amp;quot;Import:&amp;quot; /var/www/html/.last_import | cut -d&#039; &#039; -f2&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;none&amp;quot;&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to check for updates&lt;br /&gt;
check_for_updates() {&lt;br /&gt;
    python3 - &amp;lt;&amp;lt; &#039;PYTHON_EOF&#039;&lt;br /&gt;
import requests&lt;br /&gt;
from bs4 import BeautifulSoup&lt;br /&gt;
import re&lt;br /&gt;
import sys&lt;br /&gt;
&lt;br /&gt;
BASE_URL = &amp;quot;https://xml.completenoobs.com/xmlDumps/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
def parse_date_from_dump(dump_name):&lt;br /&gt;
    match = re.match(r&#039;(\d{2})_(\d{2})_(\d{2})\.Noobs&#039;, dump_name)&lt;br /&gt;
    if match:&lt;br /&gt;
        day, month, year = match.groups()&lt;br /&gt;
        year_int = int(year)&lt;br /&gt;
        full_year = 2000 + year_int if year_int &amp;lt;= 49 else 1900 + year_int&lt;br /&gt;
        return (full_year, int(month), int(day))&lt;br /&gt;
    return (0, 0, 0)&lt;br /&gt;
&lt;br /&gt;
def get_latest_dump():&lt;br /&gt;
    try:&lt;br /&gt;
        response = requests.get(BASE_URL, timeout=30)&lt;br /&gt;
        response.raise_for_status()&lt;br /&gt;
        soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
        dumps = [link.get(&#039;href&#039;).rstrip(&#039;/&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                if re.match(r&#039;\d{2}_\d{2}_\d{2}\.Noobs/$&#039;, link.get(&#039;href&#039;, &#039;&#039;))]&lt;br /&gt;
        if dumps:&lt;br /&gt;
            latest = sorted(dumps, key=parse_date_from_dump, reverse=True)[0]&lt;br /&gt;
            &lt;br /&gt;
            # Get XML files from latest dump&lt;br /&gt;
            response = requests.get(f&amp;quot;{BASE_URL}{latest}/&amp;quot;, timeout=30)&lt;br /&gt;
            response.raise_for_status()&lt;br /&gt;
            soup = BeautifulSoup(response.text, &#039;html.parser&#039;)&lt;br /&gt;
            files = [link.get(&#039;href&#039;) for link in soup.find_all(&#039;a&#039;)&lt;br /&gt;
                    if link.get(&#039;href&#039;, &#039;&#039;).endswith(&#039;.xml&#039;)]&lt;br /&gt;
            &lt;br /&gt;
            if files:&lt;br /&gt;
                newest_xml = sorted(files, reverse=True)[0]&lt;br /&gt;
                print(f&amp;quot;{latest}/{newest_xml}&amp;quot;)&lt;br /&gt;
                sys.exit(0)&lt;br /&gt;
    except Exception as e:&lt;br /&gt;
        print(f&amp;quot;ERROR: {e}&amp;quot;, file=sys.stderr)&lt;br /&gt;
        sys.exit(1)&lt;br /&gt;
    &lt;br /&gt;
    print(&amp;quot;ERROR: No dumps found&amp;quot;, file=sys.stderr)&lt;br /&gt;
    sys.exit(1)&lt;br /&gt;
&lt;br /&gt;
get_latest_dump()&lt;br /&gt;
PYTHON_EOF&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to download new XML&lt;br /&gt;
download_xml() {&lt;br /&gt;
    local dump_info=&amp;quot;$1&amp;quot;&lt;br /&gt;
    local dump_dir=$(echo &amp;quot;$dump_info&amp;quot; | cut -d&#039;/&#039; -f1)&lt;br /&gt;
    local xml_file=$(echo &amp;quot;$dump_info&amp;quot; | cut -d&#039;/&#039; -f2)&lt;br /&gt;
    local url=&amp;quot;https://xml.completenoobs.com/xmlDumps/${dump_info}&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Downloading: $xml_file&amp;quot;&lt;br /&gt;
    echo &amp;quot;From: $dump_dir&amp;quot;&lt;br /&gt;
    echo &amp;quot;URL: $url&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    if wget -O /tmp/new_dump.xml &amp;quot;$url&amp;quot; --progress=bar:force 2&amp;gt;&amp;amp;1; then&lt;br /&gt;
        echo &amp;quot;Download successful!&amp;quot;&lt;br /&gt;
        return 0&lt;br /&gt;
    else&lt;br /&gt;
        echo &amp;quot;Download failed!&amp;quot;&lt;br /&gt;
        return 1&lt;br /&gt;
    fi&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to backup current database&lt;br /&gt;
backup_database() {&lt;br /&gt;
    echo &amp;quot;Creating database backup...&amp;quot;&lt;br /&gt;
    TIMESTAMP=$(date +%Y%m%d_%H%M%S)&lt;br /&gt;
    mysqldump --user=wikiuser --password=wikipass completenoobs_wiki &amp;gt; /tmp/wiki_backup_${TIMESTAMP}.sql&lt;br /&gt;
    echo &amp;quot;Backup created: /tmp/wiki_backup_${TIMESTAMP}.sql&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to analyze and import changes&lt;br /&gt;
analyze_and_import() {&lt;br /&gt;
    echo &amp;quot;Analyzing differences between XML and local wiki...&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Create analysis and import script for MediaWiki 1.44+&lt;br /&gt;
    cat &amp;gt; /tmp/analyze_import.php &amp;lt;&amp;lt; &#039;PHP_EOF&#039;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
require_once &#039;/var/www/html/maintenance/Maintenance.php&#039;;&lt;br /&gt;
&lt;br /&gt;
class AnalyzeAndImport extends Maintenance {&lt;br /&gt;
    private $db;&lt;br /&gt;
    private $new_pages = [];&lt;br /&gt;
    private $changed_pages = [];&lt;br /&gt;
    private $unchanged_pages = [];&lt;br /&gt;
    &lt;br /&gt;
    public function __construct() {&lt;br /&gt;
        parent::__construct();&lt;br /&gt;
        $this-&amp;gt;addDescription(&#039;Analyze and selectively import from XML dump&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    public function execute() {&lt;br /&gt;
        $this-&amp;gt;db = new mysqli(&#039;127.0.0.1&#039;, &#039;wikiuser&#039;, &#039;wikipass&#039;, &#039;completenoobs_wiki&#039;);&lt;br /&gt;
        &lt;br /&gt;
        // MediaWiki 1.35+ uses slots and content tables&lt;br /&gt;
        // Get existing pages with their content&lt;br /&gt;
        $query = &amp;quot;&lt;br /&gt;
            SELECT p.page_title, p.page_id, c.content_address, c.content_sha1&lt;br /&gt;
            FROM page p&lt;br /&gt;
            JOIN revision r ON p.page_latest = r.rev_id&lt;br /&gt;
            JOIN slots s ON r.rev_id = s.slot_revision_id&lt;br /&gt;
            JOIN slot_roles sr ON s.slot_role_id = sr.role_id&lt;br /&gt;
            JOIN content c ON s.slot_content_id = c.content_id&lt;br /&gt;
            WHERE p.page_namespace = 0 AND sr.role_name = &#039;main&#039;&lt;br /&gt;
        &amp;quot;;&lt;br /&gt;
        &lt;br /&gt;
        $result = $this-&amp;gt;db-&amp;gt;query($query);&lt;br /&gt;
        if (!$result) {&lt;br /&gt;
            $this-&amp;gt;error(&amp;quot;Database query failed: &amp;quot; . $this-&amp;gt;db-&amp;gt;error);&lt;br /&gt;
            return;&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $existing = [];&lt;br /&gt;
        while ($row = $result-&amp;gt;fetch_assoc()) {&lt;br /&gt;
            // Get actual text content&lt;br /&gt;
            $text_content = $this-&amp;gt;getTextContent($row[&#039;content_address&#039;]);&lt;br /&gt;
            $existing[$row[&#039;page_title&#039;]] = [&lt;br /&gt;
                &#039;id&#039; =&amp;gt; $row[&#039;page_id&#039;],&lt;br /&gt;
                &#039;content&#039; =&amp;gt; $text_content,&lt;br /&gt;
                &#039;sha1&#039; =&amp;gt; $row[&#039;content_sha1&#039;]&lt;br /&gt;
            ];&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Parse XML and compare&lt;br /&gt;
        $xml = simplexml_load_file(&#039;/tmp/new_dump.xml&#039;);&lt;br /&gt;
        &lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            $title = str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title);&lt;br /&gt;
            $xml_content = (string)$page-&amp;gt;revision-&amp;gt;text;&lt;br /&gt;
            &lt;br /&gt;
            if (!isset($existing[$title])) {&lt;br /&gt;
                // New page&lt;br /&gt;
                $this-&amp;gt;new_pages[$title] = $xml_content;&lt;br /&gt;
            } else {&lt;br /&gt;
                // Compare content using SHA1 for efficiency&lt;br /&gt;
                $xml_sha1 = sha1($xml_content);&lt;br /&gt;
                &lt;br /&gt;
                if ($existing[$title][&#039;sha1&#039;] !== $xml_sha1) {&lt;br /&gt;
                    // Content is different&lt;br /&gt;
                    $this-&amp;gt;changed_pages[$title] = [&lt;br /&gt;
                        &#039;local&#039; =&amp;gt; $existing[$title][&#039;content&#039;],&lt;br /&gt;
                        &#039;xml&#039; =&amp;gt; $xml_content,&lt;br /&gt;
                        &#039;page_id&#039; =&amp;gt; $existing[$title][&#039;id&#039;]&lt;br /&gt;
                    ];&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;unchanged_pages[] = $title;&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Display summary&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;\n=== Update Analysis ===\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;New pages to import: &amp;quot; . count($this-&amp;gt;new_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Changed pages found: &amp;quot; . count($this-&amp;gt;changed_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Unchanged pages: &amp;quot; . count($this-&amp;gt;unchanged_pages) . &amp;quot;\n&amp;quot;);&lt;br /&gt;
        &lt;br /&gt;
        // Save analysis for review&lt;br /&gt;
        file_put_contents(&#039;/tmp/update_analysis.json&#039;, json_encode([&lt;br /&gt;
            &#039;new&#039; =&amp;gt; array_keys($this-&amp;gt;new_pages),&lt;br /&gt;
            &#039;changed&#039; =&amp;gt; array_keys($this-&amp;gt;changed_pages),&lt;br /&gt;
            &#039;unchanged&#039; =&amp;gt; $this-&amp;gt;unchanged_pages&lt;br /&gt;
        ], JSON_PRETTY_PRINT));&lt;br /&gt;
        &lt;br /&gt;
        // Show changed pages with preview (limit to first 20 for readability)&lt;br /&gt;
        if (count($this-&amp;gt;changed_pages) &amp;gt; 0) {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Changed Pages ===\n&amp;quot;);&lt;br /&gt;
            $count = 0;&lt;br /&gt;
            $total_changed = count($this-&amp;gt;changed_pages);&lt;br /&gt;
            &lt;br /&gt;
            foreach ($this-&amp;gt;changed_pages as $title =&amp;gt; $data) {&lt;br /&gt;
                $count++;&lt;br /&gt;
                if ($count &amp;lt;= 20) {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;\n$count. $title\n&amp;quot;);&lt;br /&gt;
                    &lt;br /&gt;
                    // Create a simple diff preview (first 300 chars)&lt;br /&gt;
                    $xml_preview = substr($data[&#039;xml&#039;], 0, 100);&lt;br /&gt;
                    &lt;br /&gt;
                    // Save full diff to file&lt;br /&gt;
                    $safe_title = preg_replace(&#039;/[^a-zA-Z0-9_-]/&#039;, &#039;_&#039;, $title);&lt;br /&gt;
                    $diff_file = &amp;quot;/tmp/diff_${safe_title}.txt&amp;quot;;&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;=== FULL DIFF FOR: $title ===\n\n&amp;quot;);&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;--- LOCAL VERSION ---\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, $data[&#039;local&#039;] . &amp;quot;\n\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, &amp;quot;--- XML VERSION ---\n&amp;quot;, FILE_APPEND);&lt;br /&gt;
                    file_put_contents($diff_file, $data[&#039;xml&#039;], FILE_APPEND);&lt;br /&gt;
                    &lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;   Preview: &amp;quot; . $xml_preview . &amp;quot;...\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
            &lt;br /&gt;
            if ($total_changed &amp;gt; 20) {&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;\n... and &amp;quot; . ($total_changed - 20) . &amp;quot; more changed pages.\n&amp;quot;);&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;All diff files saved to /tmp/diff_*.txt\n&amp;quot;);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Interactive selection&lt;br /&gt;
        if (count($this-&amp;gt;new_pages) &amp;gt; 0 || count($this-&amp;gt;changed_pages) &amp;gt; 0) {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Import Options ===\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;1. Import new pages only (preserve all local changes)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;2. Import new pages + update ALL changed pages (overwrites local changes)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;3. Selective import (choose which updates to apply)\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;4. Cancel (no changes)\n&amp;quot;);&lt;br /&gt;
            &lt;br /&gt;
            // Save state for import script WITHOUT the XML object&lt;br /&gt;
            $import_data = [&lt;br /&gt;
                &#039;new_pages&#039; =&amp;gt; $this-&amp;gt;new_pages,&lt;br /&gt;
                &#039;changed_pages&#039; =&amp;gt; $this-&amp;gt;changed_pages,&lt;br /&gt;
                &#039;xml_file&#039; =&amp;gt; &#039;/tmp/new_dump.xml&#039;  // Save path instead of object&lt;br /&gt;
            ];&lt;br /&gt;
            &lt;br /&gt;
            file_put_contents(&#039;/tmp/import_data.ser&#039;, serialize($import_data));&lt;br /&gt;
        } else {&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\nNo changes detected. Your wiki is up to date!\n&amp;quot;);&lt;br /&gt;
        }&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function getTextContent($address) {&lt;br /&gt;
        // Handle different content storage formats in MW 1.35+&lt;br /&gt;
        if (strpos($address, &#039;tt:&#039;) === 0) {&lt;br /&gt;
            // Text table reference&lt;br /&gt;
            $text_id = substr($address, 3);&lt;br /&gt;
            $result = $this-&amp;gt;db-&amp;gt;query(&amp;quot;SELECT old_text FROM text WHERE old_id = $text_id&amp;quot;);&lt;br /&gt;
            if ($row = $result-&amp;gt;fetch_assoc()) {&lt;br /&gt;
                return $row[&#039;old_text&#039;];&lt;br /&gt;
            }&lt;br /&gt;
        } elseif (strpos($address, &#039;es:&#039;) === 0) {&lt;br /&gt;
            // External storage - would need special handling&lt;br /&gt;
            return &amp;quot;[External storage content]&amp;quot;;&lt;br /&gt;
        }&lt;br /&gt;
        // Direct content&lt;br /&gt;
        return $address;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
$maintClass = AnalyzeAndImport::class;&lt;br /&gt;
require_once RUN_MAINTENANCE_IF_MAIN;&lt;br /&gt;
PHP_EOF&lt;br /&gt;
&lt;br /&gt;
    cd /var/www/html&lt;br /&gt;
    php /tmp/analyze_import.php&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Function to perform selected import&lt;br /&gt;
perform_import() {&lt;br /&gt;
    local choice=$1&lt;br /&gt;
    &lt;br /&gt;
    cat &amp;gt; /tmp/do_import.php &amp;lt;&amp;lt; &#039;PHP_EOF&#039;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
require_once &#039;/var/www/html/maintenance/Maintenance.php&#039;;&lt;br /&gt;
&lt;br /&gt;
class DoImport extends Maintenance {&lt;br /&gt;
    public function __construct() {&lt;br /&gt;
        parent::__construct();&lt;br /&gt;
        $this-&amp;gt;addOption(&#039;mode&#039;, &#039;Import mode&#039;, true, true);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    public function execute() {&lt;br /&gt;
        $mode = $this-&amp;gt;getOption(&#039;mode&#039;);&lt;br /&gt;
        $data = unserialize(file_get_contents(&#039;/tmp/import_data.ser&#039;));&lt;br /&gt;
        &lt;br /&gt;
        // Load XML file&lt;br /&gt;
        $xml = simplexml_load_file($data[&#039;xml_file&#039;]);&lt;br /&gt;
        &lt;br /&gt;
        $new_imported = 0;&lt;br /&gt;
        $updated = 0;&lt;br /&gt;
        &lt;br /&gt;
        // Import new pages (always for modes 1-3)&lt;br /&gt;
        if ($mode != &#039;4&#039;) {&lt;br /&gt;
            foreach ($data[&#039;new_pages&#039;] as $title =&amp;gt; $content) {&lt;br /&gt;
                $this-&amp;gt;importPage($title, $content, $xml);&lt;br /&gt;
                $new_imported++;&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Imported new page: $title\n&amp;quot;);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Handle changed pages based on mode&lt;br /&gt;
        if ($mode == &#039;2&#039;) {&lt;br /&gt;
            // Update all changed pages&lt;br /&gt;
            foreach ($data[&#039;changed_pages&#039;] as $title =&amp;gt; $info) {&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Updating: $title\n&amp;quot;);&lt;br /&gt;
                if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                    $updated++;&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Updated page: $title\n&amp;quot;);&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Failed to update: $title\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        } elseif ($mode == &#039;3&#039;) {&lt;br /&gt;
            // Selective update&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;\n=== Selective Import Mode ===\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;For each changed page, choose:\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  y = yes, update this page\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  n = no, keep local version\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  d = show diff file\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  a = update all remaining pages\n&amp;quot;);&lt;br /&gt;
            $this-&amp;gt;output(&amp;quot;  s = skip all remaining pages\n\n&amp;quot;);&lt;br /&gt;
            &lt;br /&gt;
            $update_all = false;&lt;br /&gt;
            $skip_all = false;&lt;br /&gt;
            &lt;br /&gt;
            foreach ($data[&#039;changed_pages&#039;] as $title =&amp;gt; $info) {&lt;br /&gt;
                if ($skip_all) {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                    continue;&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                if ($update_all) {&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                    continue;&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;\nPage: $title\n&amp;quot;);&lt;br /&gt;
                $this-&amp;gt;output(&amp;quot;Action (y/n/d/a/s): &amp;quot;);&lt;br /&gt;
                $handle = fopen(&amp;quot;php://stdin&amp;quot;, &amp;quot;r&amp;quot;);&lt;br /&gt;
                $line = trim(fgets($handle));&lt;br /&gt;
                &lt;br /&gt;
                while ($line == &#039;d&#039;) {&lt;br /&gt;
                    // Show diff&lt;br /&gt;
                    $safe_title = preg_replace(&#039;/[^a-zA-Z0-9_-]/&#039;, &#039;_&#039;, $title);&lt;br /&gt;
                    $diff_file = &amp;quot;/tmp/diff_${safe_title}.txt&amp;quot;;&lt;br /&gt;
                    if (file_exists($diff_file)) {&lt;br /&gt;
                        system(&amp;quot;head -50 $diff_file&amp;quot;);&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;\n[Showing first 50 lines - full file at $diff_file]\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Action (y/n/a/s): &amp;quot;);&lt;br /&gt;
                    $line = trim(fgets($handle));&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                if ($line == &#039;a&#039;) {&lt;br /&gt;
                    $update_all = true;&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                } elseif ($line == &#039;s&#039;) {&lt;br /&gt;
                    $skip_all = true;&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                } elseif ($line == &#039;y&#039;) {&lt;br /&gt;
                    if ($this-&amp;gt;reimportPage($title, $xml)) {&lt;br /&gt;
                        $updated++;&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Updated: $title\n&amp;quot;);&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $this-&amp;gt;output(&amp;quot;Failed to update: $title\n&amp;quot;);&lt;br /&gt;
                    }&lt;br /&gt;
                } else {&lt;br /&gt;
                    $this-&amp;gt;output(&amp;quot;Skipped: $title\n&amp;quot;);&lt;br /&gt;
                }&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;\n=== Import Complete ===\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;New pages imported: $new_imported\n&amp;quot;);&lt;br /&gt;
        $this-&amp;gt;output(&amp;quot;Pages updated: $updated\n&amp;quot;);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function importPage($title, $content, $xml) {&lt;br /&gt;
        // Create single page XML for import&lt;br /&gt;
        $tempFile = &#039;/tmp/single_page_&#039; . md5($title) . &#039;.xml&#039;;&lt;br /&gt;
        $singlePage = new SimpleXMLElement(&#039;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;&amp;lt;mediawiki&amp;gt;&amp;lt;/mediawiki&amp;gt;&#039;);&lt;br /&gt;
        &lt;br /&gt;
        if ($xml-&amp;gt;siteinfo) {&lt;br /&gt;
            $siteinfo = $singlePage-&amp;gt;addChild(&#039;siteinfo&#039;);&lt;br /&gt;
            foreach ($xml-&amp;gt;siteinfo-&amp;gt;children() as $child) {&lt;br /&gt;
                $siteinfo-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        // Find and add the page&lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            if (str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title) == $title) {&lt;br /&gt;
                $newPage = $singlePage-&amp;gt;addChild(&#039;page&#039;);&lt;br /&gt;
                foreach ($page-&amp;gt;children() as $child) {&lt;br /&gt;
                    if ($child-&amp;gt;getName() == &#039;revision&#039;) {&lt;br /&gt;
                        $revision = $newPage-&amp;gt;addChild(&#039;revision&#039;);&lt;br /&gt;
                        foreach ($child-&amp;gt;children() as $revChild) {&lt;br /&gt;
                            $revision-&amp;gt;addChild($revChild-&amp;gt;getName(), (string)$revChild);&lt;br /&gt;
                        }&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $newPage-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                break;&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        $singlePage-&amp;gt;asXML($tempFile);&lt;br /&gt;
        exec(&amp;quot;php /var/www/html/maintenance/importDump.php &amp;lt; $tempFile 2&amp;gt;&amp;amp;1&amp;quot;);&lt;br /&gt;
        unlink($tempFile);&lt;br /&gt;
    }&lt;br /&gt;
    &lt;br /&gt;
    private function reimportPage($title, $xml) {&lt;br /&gt;
        // For updating existing pages, delete then reimport&lt;br /&gt;
        $db = new mysqli(&#039;127.0.0.1&#039;, &#039;wikiuser&#039;, &#039;wikipass&#039;, &#039;completenoobs_wiki&#039;);&lt;br /&gt;
        &lt;br /&gt;
        // Delete the existing page&lt;br /&gt;
        $safe_title = $db-&amp;gt;real_escape_string(str_replace(&#039; &#039;, &#039;_&#039;, $title));&lt;br /&gt;
        $db-&amp;gt;query(&amp;quot;DELETE FROM page WHERE page_title = &#039;$safe_title&#039; AND page_namespace = 0&amp;quot;);&lt;br /&gt;
        &lt;br /&gt;
        $db-&amp;gt;close();&lt;br /&gt;
        &lt;br /&gt;
        // Now import the new version&lt;br /&gt;
        foreach ($xml-&amp;gt;page as $page) {&lt;br /&gt;
            if (str_replace(&#039; &#039;, &#039;_&#039;, (string)$page-&amp;gt;title) == $title) {&lt;br /&gt;
                $tempFile = &#039;/tmp/update_page_&#039; . md5($title) . &#039;.xml&#039;;&lt;br /&gt;
                $singlePage = new SimpleXMLElement(&#039;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;&amp;lt;mediawiki&amp;gt;&amp;lt;/mediawiki&amp;gt;&#039;);&lt;br /&gt;
                &lt;br /&gt;
                if ($xml-&amp;gt;siteinfo) {&lt;br /&gt;
                    $siteinfo = $singlePage-&amp;gt;addChild(&#039;siteinfo&#039;);&lt;br /&gt;
                    foreach ($xml-&amp;gt;siteinfo-&amp;gt;children() as $child) {&lt;br /&gt;
                        $siteinfo-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $newPage = $singlePage-&amp;gt;addChild(&#039;page&#039;);&lt;br /&gt;
                foreach ($page-&amp;gt;children() as $child) {&lt;br /&gt;
                    if ($child-&amp;gt;getName() == &#039;revision&#039;) {&lt;br /&gt;
                        $revision = $newPage-&amp;gt;addChild(&#039;revision&#039;);&lt;br /&gt;
                        foreach ($child-&amp;gt;children() as $revChild) {&lt;br /&gt;
                            $revision-&amp;gt;addChild($revChild-&amp;gt;getName(), (string)$revChild);&lt;br /&gt;
                        }&lt;br /&gt;
                    } else {&lt;br /&gt;
                        $newPage-&amp;gt;addChild($child-&amp;gt;getName(), (string)$child);&lt;br /&gt;
                    }&lt;br /&gt;
                }&lt;br /&gt;
                &lt;br /&gt;
                $singlePage-&amp;gt;asXML($tempFile);&lt;br /&gt;
                $result = exec(&amp;quot;php /var/www/html/maintenance/importDump.php &amp;lt; $tempFile 2&amp;gt;&amp;amp;1&amp;quot;, $output, $return);&lt;br /&gt;
                unlink($tempFile);&lt;br /&gt;
                &lt;br /&gt;
                return ($return === 0);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
        &lt;br /&gt;
        return false;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
$maintClass = DoImport::class;&lt;br /&gt;
require_once RUN_MAINTENANCE_IF_MAIN;&lt;br /&gt;
PHP_EOF&lt;br /&gt;
&lt;br /&gt;
    cd /var/www/html&lt;br /&gt;
    php /tmp/do_import.php --mode=&amp;quot;$choice&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Main execution&lt;br /&gt;
main() {&lt;br /&gt;
    check_environment&lt;br /&gt;
    &lt;br /&gt;
    # Start MariaDB if not running&lt;br /&gt;
    service mariadb status &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 || service mariadb start&lt;br /&gt;
    &lt;br /&gt;
    # Wait for MariaDB&lt;br /&gt;
    for i in {1..30}; do&lt;br /&gt;
        if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
            break&lt;br /&gt;
        fi&lt;br /&gt;
        sleep 1&lt;br /&gt;
    done&lt;br /&gt;
    &lt;br /&gt;
    CURRENT=$(get_current_version)&lt;br /&gt;
    echo &amp;quot;Current version: $CURRENT&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Checking for updates...&amp;quot;&lt;br /&gt;
    LATEST=$(check_for_updates 2&amp;gt;/dev/null)&lt;br /&gt;
    if [ $? -ne 0 ] || [ -z &amp;quot;$LATEST&amp;quot; ] || [[ &amp;quot;$LATEST&amp;quot; == *&amp;quot;ERROR&amp;quot;* ]]; then&lt;br /&gt;
        echo &amp;quot;Failed to check for updates&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;Latest available: $LATEST&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Always proceed to analysis even if versions match&lt;br /&gt;
    # (there might be content updates in the same version)&lt;br /&gt;
    echo &amp;quot;Proceeding with content analysis...&amp;quot;&lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    &lt;br /&gt;
    # Backup database&lt;br /&gt;
    backup_database&lt;br /&gt;
    &lt;br /&gt;
    # Download new XML&lt;br /&gt;
    if ! download_xml &amp;quot;$LATEST&amp;quot;; then&lt;br /&gt;
        echo &amp;quot;Failed to download new XML&amp;quot;&lt;br /&gt;
        exit 1&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Analyze differences&lt;br /&gt;
    analyze_and_import&lt;br /&gt;
    &lt;br /&gt;
    # Check if there are changes to import&lt;br /&gt;
    if [ -f &amp;quot;/tmp/import_data.ser&amp;quot; ]; then&lt;br /&gt;
        echo &amp;quot;&amp;quot;&lt;br /&gt;
        read -p &amp;quot;Choose option (1-4): &amp;quot; -n 1 -r&lt;br /&gt;
        echo&lt;br /&gt;
        &lt;br /&gt;
        if [[ $REPLY =~ ^[1-4]$ ]]; then&lt;br /&gt;
            if [ &amp;quot;$REPLY&amp;quot; != &amp;quot;4&amp;quot; ]; then&lt;br /&gt;
                perform_import &amp;quot;$REPLY&amp;quot;&lt;br /&gt;
                &lt;br /&gt;
                # Update version info&lt;br /&gt;
                echo &amp;quot;Import: $LATEST&amp;quot; &amp;gt; /var/www/html/.last_import&lt;br /&gt;
                echo &amp;quot;Date: $(date)&amp;quot; &amp;gt;&amp;gt; /var/www/html/.last_import&lt;br /&gt;
                &lt;br /&gt;
                # Rebuild indices&lt;br /&gt;
                echo &amp;quot;Rebuilding indices...&amp;quot;&lt;br /&gt;
                php maintenance/rebuildrecentchanges.php&lt;br /&gt;
                php maintenance/initSiteStats.php&lt;br /&gt;
            else&lt;br /&gt;
                echo &amp;quot;Update cancelled&amp;quot;&lt;br /&gt;
            fi&lt;br /&gt;
        else&lt;br /&gt;
            echo &amp;quot;Invalid option. Update cancelled&amp;quot;&lt;br /&gt;
        fi&lt;br /&gt;
    fi&lt;br /&gt;
    &lt;br /&gt;
    # Clean up temp files&lt;br /&gt;
    rm -f /tmp/import_data.ser /tmp/update_analysis.json /tmp/diff_*.txt /tmp/analyze_import.php /tmp/do_import.php 2&amp;gt;/dev/null&lt;br /&gt;
    &lt;br /&gt;
    echo &amp;quot;&amp;quot;&lt;br /&gt;
    echo &amp;quot;Done!&amp;quot;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
# Run main function&lt;br /&gt;
main &amp;quot;$@&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2.5: Entrypoint Script ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
nano entrypoint.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;Starting CompleteNoobs Wiki...&amp;quot;&lt;br /&gt;
&lt;br /&gt;
service mariadb start&lt;br /&gt;
&lt;br /&gt;
# Wait for MariaDB&lt;br /&gt;
for i in {1..30}; do&lt;br /&gt;
    if mysql -e &amp;quot;SELECT 1;&amp;quot; &amp;amp;&amp;gt;/dev/null; then&lt;br /&gt;
        echo &amp;quot;MariaDB ready!&amp;quot;&lt;br /&gt;
        break&lt;br /&gt;
    fi&lt;br /&gt;
    sleep 1&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;CompleteNoobs Wiki ready at: http://localhost:8080&amp;quot;&lt;br /&gt;
echo &amp;quot;Admin login: admin / AdminPass123!&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;Features:&amp;quot;&lt;br /&gt;
echo &amp;quot;- Complete wiki content imported from XML&amp;quot;&lt;br /&gt;
echo &amp;quot;- License notices on all pages (via PageNotice)&amp;quot;&lt;br /&gt;
echo &amp;quot;- SyntaxHighlight for code blocks&amp;quot;&lt;br /&gt;
echo &amp;quot;- YouTube video embedding&amp;quot;&lt;br /&gt;
echo &amp;quot;- Contribution Scores special page&amp;quot;&lt;br /&gt;
echo &amp;quot;- XML update system (preserves local edits)&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
echo &amp;quot;To check for updates: docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&amp;quot;&lt;br /&gt;
echo &amp;quot;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
apache2-foreground&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 3: Build and Run ==&lt;br /&gt;
&lt;br /&gt;
=== 3.1: Build the Image ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker build -t completenoobs/wiki:latest .&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This will take several minutes.&lt;br /&gt;
&lt;br /&gt;
=== 3.2: Run the Container ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki completenoobs/wiki:latest&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 4: Test Everything ==&lt;br /&gt;
&lt;br /&gt;
=== 4.1: Check the Wiki ===&lt;br /&gt;
* Visit: http://localhost:8080&lt;br /&gt;
* You should see the PageNotice at the top&lt;br /&gt;
* Login with: admin / AdminPass123!&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;toccolours mw-collapsible mw-collapsed&amp;quot;&amp;gt;&lt;br /&gt;
Change Admin Password:&lt;br /&gt;
&amp;lt;div class=&amp;quot;mw-collapsible-content&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
By default, the admin user&#039;s password is &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;. It&#039;s highly recommended to change this immediately. You can do this either through the wiki&#039;s web interface or directly in the Docker terminal.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 1: Change Password via Web Interface&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
This is the easiest method. You can change your password directly from the wiki itself.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. Log in to your wiki with the default credentials: &amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
2. Once logged in, click your username (&amp;lt;code&amp;gt;admin&amp;lt;/code&amp;gt;) in the top-right corner of the page.&amp;lt;br&amp;gt;&lt;br /&gt;
3. From the drop-down menu, select &amp;lt;b&amp;gt;Preferences&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
4. On the Preferences page, go to the &amp;lt;b&amp;gt;Password&amp;lt;/b&amp;gt; tab.&amp;lt;br&amp;gt;&lt;br /&gt;
5. Enter the current password (&amp;lt;code&amp;gt;AdminPass123!&amp;lt;/code&amp;gt;), then enter your new password twice.&amp;lt;br&amp;gt;&lt;br /&gt;
6. Click &amp;lt;b&amp;gt;Change password&amp;lt;/b&amp;gt;.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Your password is now changed, and you will need to use the new one for future logins.&amp;lt;br&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;b&amp;gt;Method 2: Change Password via Terminal (No-Email Reset)&amp;lt;/b&amp;gt;&amp;lt;br&amp;gt;&lt;br /&gt;
If you have forgotten the password or prefer to use the command line, you can reset it directly inside the Docker container using a MediaWiki maintenance script.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
1. &amp;lt;b&amp;gt;Access the container&#039;s shell&amp;lt;/b&amp;gt; with the following command from your host machine:&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
2. Once inside the container, use the &amp;lt;code&amp;gt;changePassword.php&amp;lt;/code&amp;gt; maintenance script to change the password. This is the modern, recommended way to run MediaWiki maintenance scripts.&amp;lt;br&amp;gt;&lt;br /&gt;
* Change &amp;lt;b&amp;gt;NEWPASSWORD&amp;lt;/b&amp;gt; to your new password&amp;lt;br&amp;gt;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
php /var/www/html/maintenance/run.php changePassword.php --user=admin --password=NEWPASSWORD&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
3. Type &amp;lt;code&amp;gt;exit&amp;lt;/code&amp;gt; to leave the container&#039;s shell.&amp;lt;br&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The admin password has now been reset. You can log in to your wiki with the new password.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.2: Test Extensions ===&lt;br /&gt;
* &#039;&#039;&#039;YouTube&#039;&#039;&#039;: Edit any page, add &amp;lt;code&amp;gt;&amp;lt;youtube&amp;gt;N9qYF9DZPdw&amp;lt;/youtube&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;PageNotice&#039;&#039;&#039;: Should already be visible at the top&lt;br /&gt;
* &#039;&#039;&#039;SyntaxHighlight&#039;&#039;&#039;: Add code blocks with &amp;lt;nowiki&amp;gt;&amp;lt;code&amp;gt;&amp;lt;syntaxhighlight lang=&amp;quot;python&amp;quot;&amp;gt;code here&amp;lt;/syntaxhighlight&amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/nowiki&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4.3: Check Status ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki /var/www/html/check_status.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 5: XML Update Operations ==&lt;br /&gt;
*NOTE: update_xml.sh still needs alot of work, this just idea placeholder for now.&lt;br /&gt;
=== 5.1: Check for Updates (Interactive) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki /var/www/html/check_updates.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
This will:&lt;br /&gt;
* Check the CompleteNoobs XML repository for new dumps&lt;br /&gt;
* Compare with your current version&lt;br /&gt;
* Ask for confirmation before updating&lt;br /&gt;
* Import ONLY new pages (preserves your edits)&lt;br /&gt;
* Create a backup before making changes&lt;br /&gt;
&lt;br /&gt;
=== 5.2: Force Update (Non-Interactive) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki bash -c &amp;quot;echo &#039;y&#039; | /var/www/html/update_xml.sh&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5.3: Manual Update Process ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Enter container&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&lt;br /&gt;
# 2. Check current version&lt;br /&gt;
cat /var/www/html/.last_import&lt;br /&gt;
&lt;br /&gt;
# 3. Run update&lt;br /&gt;
/var/www/html/check_updates.sh&lt;br /&gt;
&lt;br /&gt;
# 4. Exit container&lt;br /&gt;
exit&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Step 6: Troubleshooting Commands ==&lt;br /&gt;
=== Access container shell: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki bash&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Edit configuration: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec -it completenoobs_wiki nano /var/www/html/LocalSettings.php&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Check logs: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs completenoobs_wiki&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== View update logs: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec completenoobs_wiki tail -f /tmp/mediawiki-debug.log&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Complete restart: ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker stop completenoobs_wiki&lt;br /&gt;
docker rm completenoobs_wiki&lt;br /&gt;
docker run -d -p 8080:80 \&lt;br /&gt;
  -v completenoobs_mysql:/var/lib/mysql \&lt;br /&gt;
  -v completenoobs_images:/var/www/html/images \&lt;br /&gt;
  --name completenoobs_wiki completenoobs/wiki:latest&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Expected Results ==&lt;br /&gt;
* Working wiki with imported CompleteNoobs content&lt;br /&gt;
* PageNotice visible at top of all pages&lt;br /&gt;
* All extensions functional&lt;br /&gt;
* Text editors (nano/vim) available in container&lt;br /&gt;
* Utility scripts for maintenance&lt;br /&gt;
* XML update system that preserves local edits&lt;br /&gt;
&lt;br /&gt;
==need to add==&lt;br /&gt;
* way for user to backup there local custom wiki - xml exporter&lt;/div&gt;</summary>
		<author><name>AwesomO</name></author>
	</entry>
</feed>