source: Klonkt/src/routes/admin-sites.js@ b4aaadd

Last change on this file since b4aaadd was 495b019, checked in by Bart <bart@…>, 2 days ago

Lay-outthema's: een pakket, de beheerder kiest, en de zijbalk als eerste

Een lay-out is nu een pakket: een map in src/themes/ met een theme.json (id,
naam, versie, contract, stylesheet) en de stylesheet zelf. De zijbalk is het
eerste ingebouwde thema; een thema van buiten installeren komt later en gaat
door dezelfde lader, zodat er één vorm is.

Dezelfde markup, een ander stelsel: een thema verandert geen template. Het
contract (1) is de afspraak over de ids en klassen waar het op mag bouwen; een
thema op een ander contract valt terug op de gewone kop in plaats van half te
werken. Elke regel staat onder html[data-layout="<id>"], en een toets bewaakt
dat, zodat een thema niets raakt zolang het niet gekozen is.

WIE KIEST: de beheerder, in het sitebeheer (sites.layout). Een bezoeker niet
-- de server zet data-layout zelf, het bezoekersscript met localStorage is
weg, en ?layout= werkt alleen als voorproefje voor wie de site beheert. Het
beheer houdt altijd de gewone kop, ook bij navigeren via htmx (de lay-out reist
mee in pcmsNav): een kapot thema mag je nooit buitensluiten van de instelling
waarmee je het uitzet.

Geserveerd op /themes/<id>/<versie>/<bestand>, met de versie in het pad als
cache-buster; alleen wat het pakket zelf noemt komt eruit. De actor noemt het
gekozen thema als shaer:theme, zodat Shaer het van zijn eigen site kan halen
in plaats van er zelf een te kiezen.

Co-Authored-By: Claude Opus 5.5 <claude@…>

  • Property mode set to 100644
File size: 17.9 KB
RevLine 
[7bc636b]1/**
2 * Admin: Site management — Phase E.
3 *
4 * GET /admin/sites -> list all sites
5 * GET /admin/sites/new -> create form
6 * POST /admin/sites/create -> insert + redirect to edit
7 * GET /admin/sites/:slug/edit -> edit form
8 * POST /admin/sites/:slug/save -> update
9 * POST /admin/sites/:slug/delete-> delete (refuses if site has posts)
10 *
11 * God-only (requireGod middleware). Slug is immutable after create — too
12 * many things hang off it (URLs, manifest scope, federation). If you really
13 * need to rename: delete + re-create.
14 */
15
16import express from 'express';
17import path from 'path';
18import fs from 'fs';
19import multer from 'multer';
20import { v4 as uuid } from 'uuid';
21import db from '../config/database.js';
22import { renderPage } from '../middleware/render.js';
[8cb1dc7]23import { requireGod, requireAuth, requireSiteManagerBySlug } from '../middleware/auth.js';
[7bc636b]24import ThemeService from '../services/ThemeService.js';
25import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
[8f6225c]26import { toWebp } from '../services/ImageWebpService.js';
[e2c3d09]27import { mediaDir } from '../config/paths.js';
[ccaa530]28import AP from '../services/ActivityPubService.js';
[7bc636b]29
30
31// Profile photos share the avatar directory with user avatars — same physical
32// folder, same URL prefix. Filenames are uuid-prefixed so site photos and
33// user avatars never collide.
[e2c3d09]34const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
[7bc636b]35fs.mkdirSync(PHOTO_DIR, { recursive: true });
36
37const ALLOWED_PHOTO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
38const MAX_PHOTO_BYTES = 5 * 1024 * 1024;
39const photoUpload = multer({
40 storage: multer.diskStorage({
41 destination: (req, file, cb) => cb(null, PHOTO_DIR),
42 filename: (req, file, cb) => {
43 const ext = path.extname(file.originalname || '').toLowerCase() || '.jpg';
44 cb(null, `site-${uuid()}${ext}`);
45 },
46 }),
47 limits: { fileSize: MAX_PHOTO_BYTES },
48 fileFilter: (req, file, cb) => {
49 const ext = path.extname(file.originalname || '').toLowerCase();
50 if (!ALLOWED_PHOTO_EXT.has(ext)) {
51 return cb(new Error('Alleen JPG/PNG/WebP/GIF toegestaan'));
52 }
53 cb(null, true);
54 },
55});
56
57
[ccaa530]58/**
59 * FEP-7628 aliases (alsoKnownAs): one former identity per line, as an actor
60 * URL or an @user@host handle. Handles resolve via WebFinger AT SAVE TIME on
61 * purpose — a typo'd alias that silently lands on the actor would make a later
62 * Move fail at the old server with no hint why. Throws the offending line.
63 */
[e9128bc]64export async function parseApAliases(raw, ownActorUri) {
[ccaa530]65 const lines = String(raw || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
66 if (lines.length > 5) throw new Error(lines[5] + ' (max 5)');
67 const out = [];
68 for (const line of lines) {
69 let uri = null;
70 if (/^https?:\/\//i.test(line)) uri = line;
71 else if (line.includes('@')) uri = await AP.webfingerResolve(line).catch(() => null);
72 if (!uri) throw new Error(line);
73 if (uri === ownActorUri) continue; // claiming yourself adds nothing
74 if (!out.includes(uri)) out.push(uri);
75 }
76 return out;
77}
78
[7bc636b]79const router = express.Router();
80
81// ==================== UPLOAD PROFILE PHOTO (JSON) ====================
82// POST /admin/sites/upload-photo → { ok: true, url: '/media/avatars/<filename>' }
83// Used by the admin-site-edit form's photo picker. The form itself still
84// holds the URL string in `profile_photo` — this endpoint just stores the
85// file and hands back a URL that the form can paste into the input field.
[8cb1dc7]86router.post('/upload-photo', requireAuth, (req, res) => {
[7bc636b]87 photoUpload.single('photo')(req, res, (err) => {
88 if (err) return res.status(400).json({ ok: false, error: err.message });
89 if (!req.file) return res.status(400).json({ ok: false, error: 'Geen bestand ontvangen' });
90 res.json({
91 ok: true,
[8f6225c]92 url: `/media/avatars/${toWebp(req.file)}`,
[7bc636b]93 size: req.file.size,
94 mime: req.file.mimetype,
95 });
96 });
97});
98
99const RESERVED_SITE_SLUGS = new Set([
100 'auth', 'admin', 'login', 'register', 'logout', 'archive', 'search',
[8f2f97c]101 'account', 'sites', 'comments', 'posts', 'media', 'audio',
[a1c8cb8]102 'forum', 'tag', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
[7bc636b]103 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
[318d0c2]104 'paid', 'push', 'guardian',
[7bc636b]105]);
106
107function siteEditableFields() {
108 return {
109 title: '',
110 description: '',
111 tagline: '',
112 language: 'nl',
[dd7e2a2]113 palette: 'klonkt',
114 accent: '#e8b04b',
[7bc636b]115 profile_photo: '',
116 profile_enabled: 1,
117 profile_name: '',
118 profile_bio: '',
119 is_public: 1,
120 robots_index: 1,
[74544fb]121 require_login_to_comment: 1,
[7bc636b]122 enable_audio_player: 1,
[8ea3d0d]123 comments_moderation_mode: 'moderate',
124 feed_view_default: 'grid',
[7bc636b]125 feed_view_switch: 1,
126 show_search: 1,
127 show_archive_link: 1,
128 title_template: '{title} — {site}',
129 twitter: '',
130 canonical: '',
131 google_verification: '',
132 bing_verification: '',
133 pinterest_verification: '',
134 yandex_verification: '',
135 custom_css: '',
136 custom_head_html: '',
137 custom_foot_html: '',
138 };
139}
140
[834bcc3]141/** Valid user-id for owner assignment, or null if empty/unknown. */
[98ecf51]142function validOwnerId(raw) {
143 const id = (raw || '').toString().trim();
144 if (!id) return null;
145 return db.prepare('SELECT 1 FROM users WHERE id = ?').get(id) ? id : null;
146}
147
[834bcc3]148/** Grant a user admin rights on a site (idempotent upsert). */
[98ecf51]149function grantSiteAdmin(siteId, userId) {
150 db.prepare(`
151 INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
152 ON CONFLICT(site_id, user_id) DO UPDATE SET role = 'admin'
153 `).run(siteId, userId);
154}
155
[834bcc3]156/** Candidate owners for the owner selector field (god-only). */
[98ecf51]157function listOwnerCandidates() {
158 return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
159}
160
[7bc636b]161// ==================== LIST ====================
162router.get('/', requireGod, (req, res) => {
163 const sites = db.prepare(`
164 SELECT s.id, s.slug, s.title, s.description, s.created_at,
[7881080]165 s.is_public, s.robots_index, s.is_primary,
[7bc636b]166 u.username AS owner_username,
167 (SELECT COUNT(*) FROM posts WHERE site_id = s.id) AS post_count
168 FROM sites s LEFT JOIN users u ON u.id = s.owner_id
[7881080]169 ORDER BY s.is_primary DESC, s.created_at DESC
[7bc636b]170 `).all();
171
172 renderPage(req, res, 'pages/admin-sites', {
[3487567]173 pageTitleKey: 'admin.t_sites',
[7bc636b]174 bodyClass: 'on-admin',
175 sites,
176 success: req.query.success || null,
177 error: req.query.error || null,
178 });
179});
180
181// ==================== NEW (form) ====================
182router.get('/new', requireGod, (req, res) => {
183 renderPage(req, res, 'pages/admin-site-edit', {
[0475b13]184 pageJs: 'admin-site-edit',
[3487567]185 pageTitleKey: 'admin.t_newsite',
[7bc636b]186 bodyClass: 'on-admin',
187 isNew: true,
[834bcc3]188 // ?owner=<id> (from the users page: "give this user a Klonkt") is
189 // pre-selected; otherwise defaults to the creating god.
[86793f9]190 site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
[98ecf51]191 users: listOwnerCandidates(),
[7bc636b]192 palettes: ThemeService.listPalettes(),
[495b019]193 layouts: ThemeService.listLayouts(),
[7bc636b]194 accents: ThemeService.listAccents(),
195 platforms: listPlatforms(),
[5462bab]196 apAliases: '',
[7bc636b]197 error: null,
198 });
199});
200
201// ==================== CREATE ====================
202router.post('/create', requireGod, (req, res) => {
203 const slug = (req.body.slug || '').toString().toLowerCase().trim();
204 if (!/^[a-z0-9_-]{2,40}$/.test(slug)) {
205 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug: 2-40 chars, letters/numbers/underscore/dash'));
206 }
207 if (RESERVED_SITE_SLUGS.has(slug)) {
208 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('That slug is reserved'));
209 }
210 const existing = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
211 if (existing) {
212 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug already taken'));
213 }
214
215 const f = { ...siteEditableFields(), ...req.body };
[98ecf51]216
[834bcc3]217 // Owner: god may assign the site to a DIFFERENT user — this is the core of
218 // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
219 // creating god themselves.
[98ecf51]220 const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
221
[7bc636b]222 const siteId = uuid();
223 db.prepare(`
224 INSERT INTO sites (
225 id, slug, title, description, tagline, owner_id,
226 language, palette, accent, profile_photo,
[8ea3d0d]227 is_public, robots_index, require_login_to_comment, enable_audio_player,
[abdcf33]228 feed_view_default
229 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
[7bc636b]230 `).run(
231 siteId, slug,
232 (f.title || slug).slice(0, 200),
233 (f.description || '').slice(0, 500),
234 (f.tagline || '').slice(0, 200),
[98ecf51]235 ownerId,
[7bc636b]236 f.language || 'nl',
[dd7e2a2]237 f.palette || 'klonkt',
238 ThemeService.validateAccent(f.accent) || '#e8b04b',
[7bc636b]239 f.profile_photo || null,
240 f.is_public ? 1 : 0,
241 f.robots_index ? 1 : 0,
242 f.require_login_to_comment ? 1 : 0,
243 (f.enable_audio_player !== undefined ? (f.enable_audio_player ? 1 : 0) : 1),
[70e9147]244 f.feed_view_default === 'grid' ? 'grid' : 'reader',
[7bc636b]245 );
246
[834bcc3]247 // The OWNER (not necessarily the creator) gets a site_members admin row → this
248 // lets them pass canAdminSite + requireSiteManager gates to manage their site.
[98ecf51]249 grantSiteAdmin(siteId, ownerId);
[7bc636b]250
[98ecf51]251 res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
[7bc636b]252});
253
254// ==================== EDIT (form) ====================
[8cb1dc7]255router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
[7bc636b]256 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
257 if (!site) return res.redirect('/admin/sites?error=Not+found');
258
259
[ccaa530]260 let apAliases = '';
261 try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
262
[7bc636b]263 renderPage(req, res, 'pages/admin-site-edit', {
[0475b13]264 pageJs: 'admin-site-edit',
[3487567]265 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
[7bc636b]266 bodyClass: 'on-admin',
267 isNew: false,
268 site,
[98ecf51]269 users: listOwnerCandidates(),
[7bc636b]270 palettes: ThemeService.listPalettes(),
[495b019]271 layouts: ThemeService.listLayouts(),
[7bc636b]272 accents: ThemeService.listAccents(),
273 platforms: listPlatforms(),
[ccaa530]274 apAliases,
[7bc636b]275 success: req.query.success || null,
276 error: req.query.error || null,
277 });
278});
279
[0ca7e9a4]280// ==================== MOVE (FEP-7628, slice 2) ====================
281// The explicit departure: announce to every follower that this account now
282// lives elsewhere. Deliberately its own POST with its own button, never a
283// side effect of Save: a Move is a door you close behind you.
284router.post('/:slug/move', requireSiteManagerBySlug, async (req, res) => {
285 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
286 if (!site) return res.redirect('/admin/sites?error=Not+found');
287 const r = await AP.moveAccount(site, req.body.move_target || '');
288 if (r && r.ok) {
289 return res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent(`Verhuizing aangekondigd naar ${r.target} (${r.inboxes} inboxen).`));
290 }
291 const msg = {
292 guarded_account: 'Dit account heeft guardians; verhuizen kan pas als de guardianship mee kan (shaer-tge).',
293 no_backreference: 'Het nieuwe profiel claimt dit account niet in zijn aliassen. Zet daar eerst dit adres als alias.',
294 not_found: 'Nieuw adres niet gevonden. Gebruik @naam@server of een actor-URL.',
295 unreachable: 'Het nieuwe profiel is niet bereikbaar.',
296 self: 'Dat is dit account zelf.',
297 }[r && r.error] || 'Verhuizen mislukte; probeer het opnieuw.';
298 res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(msg));
299});
300
[7bc636b]301// ==================== SAVE ====================
[ccaa530]302router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
303 const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
[7bc636b]304 if (!site) return res.redirect('/admin/sites?error=Not+found');
305
306 const f = req.body;
[e46ebaa]307 // Twee vragen, en ze hingen scheef: dit pad schreef 'timeline' terwijl het
308 // AANMAAKpad 'reader' schreef, voor precies dezelfde keuze. Elke site die ooit
309 // is opgeslagen droeg dus 'timeline', en de client vertaalde dat stil terug.
310 // Nu betekent de waarde weer wat er staat.
311 const feedAlt = ['timeline', 'auto'].includes(f.feed_alt_view) ? f.feed_alt_view : 'reader';
312 const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : feedAlt;
[746d98a]313 // De profiellinks staan sinds 30-9 bij je profiel (/account). Deze pagina
314 // schrijft ze dus NIET meer: met de velden weg zou elke keer opslaan hier ze
315 // wissen. De kolom blijft van /account/links.
[7bc636b]316
[ccaa530]317 // FEP-7628 aliases — validated/resolved before anything is written.
318 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
[e9128bc]319 // Het INVOERVELD staat hier sinds 14-8 niet meer: aliassen horen bij
320 // Migreren. Dit formulier mag ze dus niet aanraken, en al helemaal niet
321 // leegmaken omdat het veld ontbreekt. Anders verlies je je claim op je oude
322 // account door je kleuren aan te passen, en weigert de Move daarna met
323 // no_backreference. Alleen verwerken als het veld ECHT is meegestuurd, zodat
324 // een oude gecachte pagina die hem nog wel heeft blijft werken.
325 let apAliasesJson = site.ap_aliases || null;
326 if (Object.prototype.hasOwnProperty.call(f, 'ap_aliases')) {
327 try {
328 const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
329 apAliasesJson = arr.length ? JSON.stringify(arr) : null;
330 } catch (e) {
331 return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
332 }
[ccaa530]333 }
334
[7bc636b]335 // theme_override: only accept the three legal values. Empty string means
336 // "Auto" — defer to user's prefers-color-scheme on first paint.
337 const themeOverride = ['light', 'dark'].includes(f.theme_override) ? f.theme_override : '';
[495b019]338 // layout: alleen een thema dat deze Klonkt kent (experimental/theme). Leeg
339 // is de gewone kop. Een onbekend id opslaan zou een site maken die naar
340 // niets verwijst, en die valt dan stil terug -- liever meteen leeg.
341 const layout = ThemeService.getLayout(f.layout) ? f.layout : null;
[7bc636b]342
343 // accent: only accept colors from the curated ACCENTS list. Falls back to
344 // the orange default if the submitted value isn't recognised.
[dd7e2a2]345 const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
[7bc636b]346
347 db.prepare(`
348 UPDATE sites SET
349 title = ?, description = ?, tagline = ?, language = ?,
[495b019]350 palette = ?, accent = ?, theme_override = ?, layout = ?, profile_photo = ?,
[2c246d4]351 profile_enabled = ?,
[ccaa530]352 ap_aliases = ?,
[7bc636b]353 is_public = ?, robots_index = ?, require_login_to_comment = ?,
[8f2f97c]354 enable_audio_player = ?,
[acbc9fc]355 approve_followers = ?,
[520c993]356 feed_view_default = ?, feed_view_switch = ?, feed_alt_view = ?, reader_full_page = ?,
[7bc636b]357 show_search = ?, show_archive_link = ?,
358 custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
359 updated_at = CURRENT_TIMESTAMP
360 WHERE id = ?
361 `).run(
362 (f.title || '').slice(0, 200),
363 (f.description || '').slice(0, 500),
364 (f.tagline || '').slice(0, 200),
365 f.language || 'nl',
[dd7e2a2]366 f.palette || 'klonkt',
[7bc636b]367 accent,
368 themeOverride,
[495b019]369 layout,
[7bc636b]370 f.profile_photo || null,
371 f.profile_enabled ? 1 : 0,
[ccaa530]372 apAliasesJson,
[7bc636b]373 f.is_public ? 1 : 0,
374 f.robots_index ? 1 : 0,
375 f.require_login_to_comment ? 1 : 0,
376 f.enable_audio_player ? 1 : 0,
[acbc9fc]377 f.approve_followers ? 1 : 0,
[7bc636b]378 feedViewDef,
379 f.feed_view_switch ? 1 : 0,
[e46ebaa]380 feedAlt,
[520c993]381 f.reader_full_page ? 1 : 0,
[7bc636b]382 f.show_search ? 1 : 0,
383 f.show_archive_link ? 1 : 0,
384 f.custom_css || null,
385 f.custom_head_html || null,
386 f.custom_foot_html || null,
387 site.id,
388 );
389
[834bcc3]390 // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
391 // change the owner (the field is not shown to non-god users either).
[98ecf51]392 if (req.session.user.role === 'god') {
393 const newOwner = validOwnerId(req.body.owner_id);
394 if (newOwner) {
395 db.prepare('UPDATE sites SET owner_id = ? WHERE id = ?').run(newOwner, site.id);
396 grantSiteAdmin(site.id, newOwner);
397 }
398 }
399
[ccaa530]400 // Alias change → broadcast an actor Update so remote caches refresh. The old
401 // server re-fetches the actor live during a Move anyway; this is freshness,
402 // not correctness, hence best-effort.
403 if ((site.ap_aliases || null) !== apAliasesJson) {
404 try {
405 const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
406 AP.deliverActorUpdate(fresh).catch(() => {});
407 } catch { /* never blocks the save */ }
408 }
409
[98ecf51]410 res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
[7bc636b]411});
412
[834bcc3]413// ==================== MAKE PRIMARY ====================
414// God chooses which site is the primary/main site (the label/company site in hub;
415// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
[7881080]416router.post('/:slug/make-primary', requireGod, (req, res) => {
417 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
418 if (!site) return res.redirect('/admin/sites?error=Niet+gevonden');
419 db.transaction(() => {
420 db.prepare('UPDATE sites SET is_primary = 0').run();
421 db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(site.id);
422 })();
423 res.redirect('/admin/sites?success=' + encodeURIComponent('Primaire site bijgewerkt'));
424});
425
[7bc636b]426// ==================== DELETE ====================
427router.post('/:slug/delete', requireGod, (req, res) => {
428 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
429 if (!site) return res.redirect('/admin/sites?error=Not+found');
430
431 const postCount = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE site_id = ?').get(site.id).c;
432 if (postCount > 0) {
433 return res.redirect('/admin/sites?error=' + encodeURIComponent(`Cannot delete: site has ${postCount} post(s). Delete posts first.`));
434 }
435
436 // Clean up site_members and audio_tracks (no posts to worry about).
437 db.prepare('DELETE FROM site_members WHERE site_id = ?').run(site.id);
438 db.prepare('DELETE FROM audio_tracks WHERE site_id = ?').run(site.id);
439 db.prepare('DELETE FROM sites WHERE id = ?').run(site.id);
440
441 res.redirect('/admin/sites?success=' + encodeURIComponent('Site deleted'));
442});
443
444export default router;
Note: See TracBrowser for help on using the repository browser.