halkyon/apps/learn/halkyon_learn_server/lib/server.dart
Tizian 「ティツィアーン」 8d6785f5c6
feat(base): SimpleLogin's forwarding page and Learn's App Links statement (ADR 0020, step 1)
SimpleLogin takes only https redirect addresses, each registered exactly,
so the instance's SimpleLogin app registers one page on the register's
web server, /simplelogin-callback, which sends the browser on to the app
that began the sign-in: a 303 to the desktop's loopback (127.0.0.1, a port
per app) or the phone's scheme, chosen by the marker the state begins with
(`<app>-<platform>.<rest>`, the rest 1 to 512 characters of base64url),
from a fixed table, so it sends nobody anywhere else. It forwards the code
or SimpleLogin's error (`deny`) with the state and nothing else, refuses
anything malformed, repeated or not a GET with a page that repeats nothing
it was given, and keeps and logs nothing (no-store, no referrer). It is
built ahead of the sign-in itself so development can sign in through
learn.halkyon.eu's page, which the development app registers too.

Android's assetlinks.json for Halkyon Learn signed with the release key
is answered by the Caddyfile on learn.halkyon.eu alone: the one domain the
project's builds name, and the server knows no app's key. A root test
holds its fingerprint to tool/release/android-certificate.sha256. The
app's intent filter comes with the sign-in; iOS's association waits for
an Apple team ID.

The profile page's language choice and look move to common/web_page.dart,
shared by both pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 17:48:31 +02:00

148 lines
5.9 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/auth/learn_base.dart';
import 'package:halkyon_learn_server/src/dictionary/device_dictionaries.dart';
import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
import 'package:halkyon_learn_server/src/generated/serverpod.dart';
import 'package:halkyon_learn_server/src/instance/jellyfin_suggestion.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// The starting point of the Serverpod server.
Future<void> run(List<String> args) async {
// Initialize Serverpod. The generated Serverpod class is already connected
// with your project's generated code.
final pod = Serverpod(args);
// What every Halkyon server has (ADR 0014): sign-in, the account and its
// deletion, the pruning, the instance's basics, set up for Learn.
// Learn holds the instance's register of accounts (ADR 0016), telling the
// instance's other services when one goes.
HalkyonBase.configure(pod, learnBase(accounts: learnAccounts()));
pod
// Token managers validate and issue authentication keys; identity
// providers are the ways people can sign in (halkyon_base's
// auth_config.dart).
..initializeAuthServices(
tokenManagerBuilders: tokenManagerBuilders,
identityProviderBuilders: identityProviderBuilders(pod),
)
// Files live in Postgres until the self-hosted S3-compatible storage arrives later.
..addCloudStorage(DatabaseCloudStorage('private'))
..addCloudStorage(DatabaseCloudStorage('public'));
// The calls of the instance's other services, on the web server
// (ADR 0016); Caddy never forwards them.
addInstanceRoutes(pod);
// Where SimpleLogin sends a sign-in back, which forwards it to the app
// (ADR 0020): the register's page, as its SimpleLogin app is.
addSimpleLoginCallbackRoute(pod);
// Profile pages, `/@handle`, which profile links link back to (ADR 0011),
// halkyon_base's since profiles are the instance's register's (ADR 0016).
addProfilePageRoute(pod);
// What devices download to look Japanese up by themselves (ADR 0012).
addDeviceDictionaryRoute(pod);
// Start the server.
await pod.start();
// Whether a Jellyfin server answers where the instance would suggest one
// (decided 2026-10-13), so instance.info() can name it from the first ask.
unawaited(JellyfinSuggestion.of(pod).look());
// Abandoned anonymous people are pruned daily (decided 2026-09-30).
// Scheduling always adds an entry, so the previous one goes first.
await pod.futureCalls.cancel(_pruning);
await pod.futureCalls
.callRecurring(identifier: _pruning)
.cron('17 3 * * *')
.pruning
.pruneAnonymousPeople();
// The IP addresses of sign-up attempts, hourly, once the rate limit is
// done with them (decided 2026-10-10).
await pod.futureCalls.cancel(_attemptPruning);
await pod.futureCalls
.callRecurring(identifier: _attemptPruning)
.cron('27 * * * *')
.pruning
.forgetSignUpAttempts();
// The deletions the instance's members haven't confirmed yet, hourly
// (ADR 0016).
await pod.futureCalls.cancel(_deletionNotices);
await pod.futureCalls
.callRecurring(identifier: _deletionNotices)
.cron('52 * * * *')
.pruning
.tellMembersOfDeletions();
// The assistant connector went with its codes and tokens (ADR 0022): what
// an earlier server scheduled to forget them would call nothing.
await pod.futureCalls.cancel(_connectorPruning);
// The files nobody can reach any more, daily (ADR 0004, rule 5).
await pod.futureCalls.cancel(_filePruning);
await pod.futureCalls
.callRecurring(identifier: _filePruning)
.cron('47 3 * * *')
.filePruning
.pruneFiles();
// How many people learn each published studyset here (ADR 0005, rule 4).
await pod.futureCalls.cancel(_learnerCount);
await pod.futureCalls
.callRecurring(identifier: _learnerCount)
.cron('37 4 * * *')
.learnerCount
.countLearners();
// The dictionaries in the server's folder, those that changed since; in
// the background, as loading takes a minute or two. Then again hourly, as
// the dictionaries service replaces them weekly (and fills its volume
// first after an instance's first start).
unawaited(_loadDictionaries(pod));
await pod.futureCalls.cancel(_dictionaryReload);
await pod.futureCalls
.callRecurring(identifier: _dictionaryReload)
.cron('7 * * * *')
.dictionaryReload
.reloadDictionaries();
}
/// Loads the dictionaries in the server's folder ([dictionaryFolder]).
/// [loadDictionaries] throws nothing; a session that can't be had is
/// reported here, as an error in the background would end the server.
Future<void> _loadDictionaries(Serverpod pod) async {
try {
await pod.withSession((session) async {
await loadDictionaries(session, dictionaryFolder());
// What devices download, ready before the first asks (ADR 0012).
await DeviceDictionaries.of(pod).prepare(session);
});
} on Object catch (error) {
stderr.writeln('Loading the dictionaries failed: $error');
}
}
/// The identifier of the daily pruning.
const _pruning = 'prune-abandoned-anonymous-people';
/// The identifier of the hourly forgetting of sign-up attempts.
const _attemptPruning = 'forget-sign-up-attempts';
/// The identifier of the hourly telling of the members of deleted accounts.
const _deletionNotices = 'tell-members-of-deletions';
/// The identifier of the daily forgetting of the connector's expired secrets,
/// scheduled until ADR 0022.
const _connectorPruning = 'forget-connector-secrets';
/// The identifier of the daily pruning of files.
const _filePruning = 'prune-unreferenced-files';
/// The identifier of the hourly rereading of the dictionaries.
const _dictionaryReload = 'reload-dictionaries';
/// The identifier of the daily count of each published studyset's learners.
const _learnerCount = 'count-learners';