- HTML 63.9%
- C# 26.7%
- CSS 3.4%
- Python 3.3%
- JavaScript 1.8%
- Other 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| deploy | ||
| MoneroAcademy.Data | ||
| MoneroAcademy.Web | ||
| tools | ||
| .gitignore | ||
| monerica-donate-qr-code.png | ||
| monero academy.png | ||
| MoneroAcademy.sln | ||
| README.md | ||
Monero Academy
Free, ad-supported Monero education site. ASP.NET Core (.NET 10) Razor Pages + PostgreSQL, videos and images on Bunny.net, sponsors pulled live from Monerica's active sponsor feed, Tor-accessible, no user accounts, no JS required for visitors (the only script is optional watch-progress tracking).
Features
- Courses → ordered lessons with Beginner / Intermediate / Advanced levels, tags, descriptions, optional transcripts, prev/next navigation and a course playlist sidebar.
- Quizzes — anonymous, scored server-side, per-question explanations, attempt stats.
- Admin panel (
/admin) — manage courses, lessons (upload/replace/delete Bunny videos, thumbnails, related/affiliate links, tags), quizzes, comment moderation, feedback inbox, analytics (views, average watch %, completion rate), change password. - Ad-supported — sponsor slots fed by
https://app.monerica.com/sponsoredlisting/activesponsor.json(cached 15 min, expired entries filtered). Featured (primary-tier) sponsors render prominently sorted by soonest expiry; everyone else shows as a low-key list near the page foot. WhichsponsorshipTypevalues count as primary is set inSponsors:PrimaryTypes. - Downloads — per-lesson toggleable download button → short-lived signed MP4 link (302 redirect, no JS).
- SEO — canonical URLs, Open Graph, JSON-LD (Course / VideoObject), dynamic
sitemap.xml,robots.txt. - Privacy — no accounts, no third-party scripts, CSP locked down (only the Bunny player iframe),
IPs only stored as salted hashes on comments, onion service with
Onion-Locationmeta. - Comments with admin approval, honeypot + per-IP throttle + a no-JS captcha; all output Razor-encoded (XSS-safe).
- Feedback & course suggestions form (also captcha-protected) feeding the admin inbox.
Project layout
MoneroAcademy/
├── MoneroAcademy.sln
├── MoneroAcademy.Data/ # class library: entities + EF DbContext
│ ├── Models/Entities.cs
│ └── AppDbContext.cs # EnsureCreated() — no migrations needed
├── MoneroAcademy.Web/ # the ASP.NET Core app
│ ├── Program.cs # composition root, security headers, minimal APIs
│ ├── appsettings.json # config template (override in appsettings.Production.json)
│ ├── Services/
│ │ ├── BunnyService.cs # Stream + Storage + signed URLs
│ │ ├── SponsorService.cs # Monerica feed: featured (by expiry) + secondary tiers
│ │ ├── CaptchaService.cs # no-JS, no-cookie, self-hosted captcha
│ │ └── Helpers.cs # slugs, PBKDF2 hashing, comment throttle
│ ├── Pages/ # public + /admin pages
│ └── wwwroot/ # css, lesson-track.js, favicon
└── deploy/ # deploy.sh, setup-vps.sh, systemd, nginx, torrc
The data layer is a separate class library so the schema/entities can be reused and the
web app depends on them by project reference (MoneroAcademy.Web → MoneroAcademy.Data).
The Npgsql provider is wired up in the web project; the Data project only references
Microsoft.EntityFrameworkCore.
Bunny.net setup (one-time)
- Stream library (videos)
- Create a Video Library; note the Library ID →
Bunny:StreamLibraryId. - API → copy the library API key →
Bunny:StreamApiKey. - Note the library CDN hostname (e.g.
vz-xxxxxxxx-xxx.b-cdn.net) →Bunny:StreamCdnHost. - Enable "MP4 Fallback" (Encoding settings) — required for the download button.
- (Recommended) Security → enable Embed view token authentication; copy the key
→
Bunny:EmbedTokenAuthKey. Also enable CDN token authentication on the library's pull zone and copy that key →Bunny:CdnTokenAuthKey(signs the short-lived download links). Leave either key empty to serve unsigned URLs.
- Create a Video Library; note the Library ID →
- Storage zone (thumbnails/images)
- Create a Storage Zone →
Bunny:StorageZoneName; copy its password/API key →Bunny:StorageApiKey. - If the zone is not in the default region, set
Bunny:StorageRegionHost(e.g.ny.storage.bunnycdn.com). - Connect a Pull Zone to the storage zone; its hostname →
Bunny:StorageHost.
- Create a Storage Zone →
Local development
# PostgreSQL
sudo -u postgres psql -c "CREATE ROLE moneroacademy LOGIN PASSWORD 'dev';"
sudo -u postgres psql -c "CREATE DATABASE moneroacademy OWNER moneroacademy;"
# config: edit MoneroAcademy.Web/appsettings.json (connection string, Bunny keys), then
dotnet run --project MoneroAcademy.Web
Schema is created automatically on first start (EnsureCreated) and the initial admin
account is seeded from Admin:InitialUsername / Admin:InitialPassword.
Log in at /admin and change the password immediately (it's only used for seeding).
VPS deployment
# 1. On the VPS (Ubuntu/Debian), as root:
scp -r deploy/ user@vps:~ && ssh user@vps
sudo bash deploy/setup-vps.sh # postgres, nginx, tor, systemd, prod config
# 2. DNS A/AAAA record -> VPS, then on the VPS:
sudo certbot --nginx -d moneroacademy.com -d www.moneroacademy.com
# 3. Fill in Bunny keys + donation address in /var/www/moneroacademy/appsettings.Production.json
# 4. From your dev machine (edit SSH_HOST in deploy/deploy.sh first):
./deploy/deploy.sh
deploy.sh publishes a self-contained linux-x64 build (no .NET runtime needed on the VPS),
rsyncs it, and restarts the moneroacademy systemd service. The production config and
data-protection keys on the server are never overwritten.
Tor
setup-vps.sh configures the hidden service. Get the address with
cat /var/lib/tor/moneroacademy/hostname, put it (with http://) into Site:OnionUrl,
and redeploy/restart — the site then advertises it via the Onion-Location meta tag.
The site works fully without JavaScript, so it behaves well in Tor Browser on "Safest".
Operations notes
- Uploads: admin video uploads stream through nginx → Kestrel → Bunny (limit 8 GB,
proxy_request_buffering off). Replacing a video deletes the old Bunny video only after the new one uploads successfully. Duration showspendinguntil Bunny finishes encoding — use Refresh duration from Bunny on the lesson edit page. - Watch tracking: anonymous random session id in
sessionStorage; furthest-watched percent is beaconed to/api/track. No cookies, no fingerprinting. Analytics shows views, average watch %, and ≥90% completion rate per lesson. - Sponsors: fetched server-side from Monerica and cached (
Sponsors:CacheMinutes), so no visitor request ever touches a third-party server. - Backups:
pg_dump moneroacademycovers everything except the videos/images, which live on Bunny. - Comments/feedback throttle is in-memory per instance (45 s per IP) — fine for a single VPS.
Config reference (appsettings.json)
| Key | Purpose |
|---|---|
ConnectionStrings:Default |
PostgreSQL connection string |
Site:BaseUrl |
canonical https URL, used in sitemap/SEO |
Site:OnionUrl |
onion address for Onion-Location (optional) |
Site:DonationAddress |
XMR address shown in footer + /donate |
Admin:InitialUsername/Password |
seed credentials (first run only) |
Bunny:StreamLibraryId/StreamApiKey |
Stream library + API key |
Bunny:StreamCdnHost |
library CDN host (vz-….b-cdn.net) |
Bunny:EmbedTokenAuthKey |
embed token key (optional, signs player URLs) |
Bunny:CdnTokenAuthKey |
CDN token key (optional, signs download links) |
Bunny:StorageZoneName/StorageApiKey |
storage zone for images |
Bunny:StorageHost |
pull-zone host serving the images |
Bunny:StorageRegionHost |
storage API endpoint (region-specific) |
Bunny:DownloadLinkMinutes |
download link lifetime (default 15) |
Sponsors:Url/CacheMinutes |
Monerica sponsor feed + cache |
Sponsors:PrimaryTypes |
comma list of sponsorshipType values to feature prominently (case-insensitive); unmatched ones show as secondary. If none match, everything is featured by expiry. |
Notes on a few behaviours
- Captcha: self-hosted arithmetic challenge sealed in a DataProtection-signed token (no
cookies, no JS, no third party). Keys persist to
MoneroAcademy.Web/keys/(excluded from deploy rsync) so tokens and auth cookies survive restarts/deploys. Stops naive bots; layer a PoW gateway (Anubis/mCaptcha) in front if you need more. - Lowercase URLs: all generated links are lowercase and any mixed-case GET path 301-redirects to lowercase (query string untouched) for clean canonical SEO. Routing stays case-insensitive.
- Sponsor tiers: set
Sponsors:PrimaryTypesto the exact tier names in your feed.