Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c42229f78c | ||
|
|
8156c1927c | ||
|
|
088c54ddf3 |
No files matched your search
@@ -128,22 +128,8 @@ jobs:
|
|||||||
|| { echo "::error::/stargazing does not say the crawler is on"; exit 1; }
|
|| { echo "::error::/stargazing does not say the crawler is on"; exit 1; }
|
||||||
echo "::notice::serving $served"
|
echo "::notice::serving $served"
|
||||||
|
|
||||||
# @thepra is the deploy's own persona (owner decision, 2026-10-04): the server's CLI makes or keeps it, undiscoverable,
|
# The deploy no longer signs in (owner decision 2026-10-17): it kept a login of its own, deploy-smoke, whose @thepra
|
||||||
# and gives its root a new password on every deploy, so no secret is stored and nobody has to create anything.
|
# is the administrator's now. Its checks are the anonymous ones above.
|
||||||
- name: Sign in as @thepra and check the signed-in API
|
|
||||||
run: |
|
|
||||||
creds=$(cd "$WEB_ROOT" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin smoke thepra | grep -E '^deploy-smoke [^ ]+$' | tail -1)
|
|
||||||
[ -n "$creds" ] || { echo "::error::the smoke persona could not be prepared"; exit 1; }
|
|
||||||
read -r login password <<< "$creds"
|
|
||||||
echo "::add-mask::$password"
|
|
||||||
read -r token cid cs <<< "$(tools/smoke/oauth.sh "$PUBLIC_URL" "$login" "$password" thepra read)"
|
|
||||||
echo "::add-mask::$token"
|
|
||||||
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$token"
|
|
||||||
discoverable=$(curl -fsS -H "Authorization: Bearer $token" "$PUBLIC_URL/api/v1/accounts/verify_credentials" | python3 -c "import json,sys; print(json.load(sys.stdin).get('discoverable'))")
|
|
||||||
[ "$discoverable" = "False" ] || { echo "::error::@thepra is discoverable"; exit 1; }
|
|
||||||
curl -fsS -o /dev/null -X POST "$PUBLIC_URL/oauth/revoke" --data-urlencode "token=$token" \
|
|
||||||
--data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs"
|
|
||||||
echo "::notice::signed in as @thepra"
|
|
||||||
|
|
||||||
# The server fetches DB-IP Lite itself (GeoUpdater) about 30 s after it starts and then daily; the deploy only checks.
|
# The server fetches DB-IP Lite itself (GeoUpdater) about 30 s after it starts and then daily; the deploy only checks.
|
||||||
- name: Geolocation databases are current
|
- name: Geolocation databases are current
|
||||||
|
|||||||
@@ -150,15 +150,27 @@ curl-able. Outbound fetches only go to https DNS names resolving to public addre
|
|||||||
`Federation__AllowPrivateNetworks=true`, `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=true`,
|
`Federation__AllowPrivateNetworks=true`, `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=true`,
|
||||||
which startup refuses in Production.
|
which startup refuses in Production.
|
||||||
|
|
||||||
|
Sign-up is closed in production (invitations only). The server's first login is made from the client instead, while no
|
||||||
|
administrator exists (owner decision 2026-10-16, first come, first served):
|
||||||
|
- `GET /clientapi/user/setup` says whether setup waits (`ViewServerSetup.IsPending`), and `POST` takes a `SetupForm`
|
||||||
|
(login, password, first persona). That login is made the administrator, with its persona.
|
||||||
|
- `ServerSetup` (`Models/ServerSetup.cs`) closes setup for good: one document with the fixed id `setup`. The first request
|
||||||
|
to insert it wins, so two setups at once make one administrator.
|
||||||
|
- It is never given back once it names an administrator, whoever is demoted or deleted later.
|
||||||
|
- `admin promote` and `create-root --admin` seal it too, and migration `_018` seals a server that already had an
|
||||||
|
administrator.
|
||||||
|
- It is never in a backup and never dropped by a restore.
|
||||||
|
|
||||||
The admin CLI runs with every service built but nothing started (no Kestrel, no hosted services), so it can run next to
|
The admin CLI runs with every service built but nothing started (no Kestrel, no hosted services), so it can run next to
|
||||||
the live service. Sign-up is closed in production (invitations only), so the first login is made here; promoting is how
|
the live service. It is the way back when setup is closed and no administrator can sign in; promoting is how an admin is
|
||||||
an admin is made (signing up as "admin" grants nothing):
|
made (signing up as "admin" grants nothing):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/privapub.thepra.dev
|
cd /var/www/privapub.thepra.dev
|
||||||
echo '<password>' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root <login> --admin # password on stdin
|
echo '<password>' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root <login> --admin # password on stdin
|
||||||
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote <root>
|
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote <root>
|
||||||
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin smoke <persona> # the deploy's: prints "deploy-smoke <new password>"
|
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin move-persona <persona> <root> # its link moves, its Mastodon tokens end
|
||||||
|
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin delete-root <root> # as the client deletes a login: its personas go too
|
||||||
```
|
```
|
||||||
|
|
||||||
The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through
|
The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through
|
||||||
@@ -503,8 +515,9 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
|
|||||||
backup rotates out), copies where a link can't be made. `--db-only` lists them instead.
|
backup rotates out), copies where a link can't be made. `--db-only` lists them instead.
|
||||||
|
|
||||||
**Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight),
|
**Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight),
|
||||||
`EmailRecovery`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, and `AppConfiguration`
|
`EmailRecovery`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore
|
||||||
(its SMTP password; it is made again from appsettings at boot). One backup or restore runs at a time
|
never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One
|
||||||
|
backup or restore runs at a time
|
||||||
(`MaintenanceLock`, a heartbeat document; a holder silent for 2 minutes is taken over), and the media janitor purges
|
(`MaintenanceLock`, a heartbeat document; a holder silent for 2 minutes is taken over), and the media janitor purges
|
||||||
nothing meanwhile. `BackupScheduler` backs up at `Backups:NightlyAt` (03:30 UTC), or at once when it missed the night;
|
nothing meanwhile. `BackupScheduler` backs up at `Backups:NightlyAt` (03:30 UTC), or at once when it missed the night;
|
||||||
rotation keeps 7 daily and 4 weekly nightly backups, 3 pre-deploy, 3 pre-restore, and manual and uploaded ones until
|
rotation keeps 7 daily and 4 weekly nightly backups, 3 pre-deploy, 3 pre-restore, and manual and uploaded ones until
|
||||||
@@ -933,9 +946,8 @@ the owner's GoToSocial account.**
|
|||||||
discovery, instance, public timeline). Then it checks that what production should be running is running:
|
discovery, instance, public timeline). Then it checks that what production should be running is running:
|
||||||
- NodeInfo and the instance API agree on registrations (closed, invitations only);
|
- NodeInfo and the instance API agree on registrations (closed, invitations only);
|
||||||
- `/stargazing` says the crawler is on;
|
- `/stargazing` says the crawler is on;
|
||||||
- **@thepra signs in**: `PrivaPub admin smoke thepra` gives the root `deploy-smoke` a new password, `tools/smoke/oauth.sh`
|
- nothing signs in (owner decision 2026-10-17): the deploy's own login, `deploy-smoke`, gave its @thepra to the
|
||||||
runs the real OAuth flow (the pasture's `privapub_token` uses the same script), the signed-in API is checked, the
|
administrator and was deleted, and the signed-in check went with it;
|
||||||
persona must be undiscoverable, and the token is revoked;
|
|
||||||
- the DB-IP Lite databases, which the server fetches itself (`GeoUpdater`), exist and are at most 40 days old.
|
- the DB-IP Lite databases, which the server fetches itself (`GeoUpdater`), exist and are at most 40 days old.
|
||||||
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
|
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
|
||||||
`ASPNETCORE_ENVIRONMENT=Production`.
|
`ASPNETCORE_ENVIRONMENT=Production`.
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using PrivaPub.ClientModels.Resources;
|
||||||
|
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace PrivaPub.ClientModels
|
||||||
|
{
|
||||||
|
// The server's first login, made while it has no administrator (/clientapi/user/setup), with its first persona.
|
||||||
|
public class SetupForm : LoginForm
|
||||||
|
{
|
||||||
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string AvatarUserName { get; set; }
|
||||||
|
|
||||||
|
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string AvatarName { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace PrivaPub.ClientModels.User
|
||||||
|
{
|
||||||
|
public class ViewServerSetup
|
||||||
|
{
|
||||||
|
// no administrator yet: the first login made through /clientapi/user/setup becomes it
|
||||||
|
public bool IsPending { get; set; } = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.Infrastructure;
|
||||||
|
using PrivaPub.Infrastructure.Cli;
|
||||||
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
using PrivaPub.Tests.Support.Host;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Http
|
||||||
|
{
|
||||||
|
// Setup is server-wide, so each test opens it again by removing the seal, and seals it once done.
|
||||||
|
[Xunit.Collection(nameof(Exclusive))]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class ServerSetupTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
PrivaPubHost _host;
|
||||||
|
WebApplicationFactory<Program> _closed;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
_host = await PrivaPubHost.Shared();
|
||||||
|
// production's mode: sign-up closed, invitations only
|
||||||
|
_closed = _host.WithWebHostBuilder(builder => builder.UseSetting("Registrations:Mode", nameof(RegistrationMode.Invitations)));
|
||||||
|
await DB.Default.DeleteAsync<ServerSetup>(ServerSetup.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_closed == default)
|
||||||
|
return;
|
||||||
|
await ServerSetup.Seal("tests", default);
|
||||||
|
await _closed.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpClient Client(string jwt = default)
|
||||||
|
{
|
||||||
|
var client = _closed.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri(PrivaPubHost.Base + "/"), AllowAutoRedirect = false });
|
||||||
|
client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader);
|
||||||
|
client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, $"10.{Random.Shared.Next(256)}.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}");
|
||||||
|
if (jwt != default)
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Name(string prefix) => $"{prefix}{Guid.NewGuid():N}"[..20];
|
||||||
|
|
||||||
|
static object Form(string userName, string avatarUserName, string avatarName = default) =>
|
||||||
|
new { userName, password = Accounts.Password, avatarUserName, avatarName };
|
||||||
|
|
||||||
|
async Task<bool> Pending()
|
||||||
|
{
|
||||||
|
using var client = Client();
|
||||||
|
return (await client.Get("/clientapi/user/setup")).Ok().Body.Flag("isPending");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<string> Message(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
var body = await response.JsonBody();
|
||||||
|
return body["errorMessage"]?.GetValue<string>() ?? body["title"]?.GetValue<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Setup_makes_the_first_login_the_administrator_with_its_persona_and_closes()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var userName = Name("first");
|
||||||
|
var avatarUserName = Name("founder");
|
||||||
|
Assert.True(await Pending());
|
||||||
|
using var client = Client();
|
||||||
|
|
||||||
|
var response = await client.PostJson("/clientapi/user/setup", Form(userName, avatarUserName, "The Founder"));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var jwt = await response.JsonBody();
|
||||||
|
var policies = jwt["policies"]!.AsArray().Select(p => p!.GetValue<string>()).Order();
|
||||||
|
Assert.Equal(new[] { Policies.IsAdmin, Policies.IsModerator, Policies.IsUser }.Order(), policies);
|
||||||
|
using var admin = Client(jwt["token"]!.GetValue<string>());
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await admin.GetAsync("/clientapi/admin/domainblocks/list", token)).StatusCode);
|
||||||
|
var persona = Assert.Single(await (await admin.GetAsync("/clientapi/avatar/private/list", token)).JsonItems())!;
|
||||||
|
Assert.Equal(avatarUserName, persona["userName"]!.GetValue<string>());
|
||||||
|
Assert.Equal("The Founder", persona["name"]!.GetValue<string>());
|
||||||
|
var seal = await DB.Default.Find<ServerSetup>().OneAsync(ServerSetup.Name, token);
|
||||||
|
Assert.Equal(jwt["userId"]!.GetValue<string>(), seal.AdminId);
|
||||||
|
|
||||||
|
Assert.False(await Pending());
|
||||||
|
var again = await client.PostJson("/clientapi/user/setup", Form(Name("second"), Name("latecomer")));
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, again.StatusCode);
|
||||||
|
Assert.Contains("already set up", await Message(again));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Two_setups_at_once_make_one_administrator()
|
||||||
|
{
|
||||||
|
using var first = Client();
|
||||||
|
using var second = Client();
|
||||||
|
|
||||||
|
var answers = await Task.WhenAll(
|
||||||
|
first.PostJson("/clientapi/user/setup", Form(Name("racea"), Name("racera"))),
|
||||||
|
second.PostJson("/clientapi/user/setup", Form(Name("raceb"), Name("racerb"))));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Forbidden }, answers.Select(a => a.StatusCode).Order());
|
||||||
|
Assert.False(await Pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_setup_that_fails_leaves_the_server_to_set_up()
|
||||||
|
{
|
||||||
|
var taken = await _host.SignUp("taken");
|
||||||
|
var userName = Name("same");
|
||||||
|
using var client = Client();
|
||||||
|
|
||||||
|
var samePersona = await client.PostJson("/clientapi/user/setup", Form(userName, userName));
|
||||||
|
var noPersona = await client.PostJson("/clientapi/user/setup", new { userName = Name("bare"), password = Accounts.Password });
|
||||||
|
var takenLogin = await client.PostJson("/clientapi/user/setup", Form(taken.UserName, Name("takenp")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, samePersona.StatusCode);
|
||||||
|
Assert.Contains("must differ", await Message(samePersona));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, noPersona.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, takenLogin.StatusCode);
|
||||||
|
Assert.Contains("already taken", await Message(takenLogin));
|
||||||
|
Assert.True(await Pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task While_setup_waits_sign_up_stays_closed()
|
||||||
|
{
|
||||||
|
using var client = Client();
|
||||||
|
|
||||||
|
var signUp = await client.PostJson("/clientapi/user/signup", new { userName = Name("outsider"), password = Accounts.Password });
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, signUp.StatusCode);
|
||||||
|
Assert.True(await Pending());
|
||||||
|
Assert.False((await client.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Promoting_an_administrator_with_the_cli_closes_setup()
|
||||||
|
{
|
||||||
|
var root = await _host.SignUp("promoted");
|
||||||
|
Assert.True(await Pending());
|
||||||
|
|
||||||
|
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }, _host.Services));
|
||||||
|
|
||||||
|
Assert.False(await Pending());
|
||||||
|
Assert.Equal(root.Id, (await DB.Default.Find<ServerSetup>().OneAsync(ServerSetup.Name, TestContext.Current.CancellationToken)).AdminId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,27 +121,55 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
Assert.Contains(Policies.IsAdmin, stored.Policies);
|
Assert.Contains(Policies.IsAdmin, stored.Policies);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> PersonaIds(Root root)
|
||||||
|
{
|
||||||
|
using var client = _host.As(root.Jwt);
|
||||||
|
return (await (await client.GetAsync("/clientapi/avatar/private/list", TestContext.Current.CancellationToken)).JsonItems())
|
||||||
|
.Select(a => a!["id"]!.GetValue<string>()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<HttpStatusCode> VerifyCredentials(string mastodonToken)
|
||||||
|
{
|
||||||
|
using var client = _host.As(mastodonToken);
|
||||||
|
return (await client.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Smoke_keeps_one_undiscoverable_persona_and_a_new_password_each_run()
|
public async Task Move_persona_gives_a_persona_to_another_login_and_ends_its_tokens()
|
||||||
{
|
{
|
||||||
using var scope = _host.Services.CreateScope();
|
using var scope = _host.Services.CreateScope();
|
||||||
var persona = $"smoke{Guid.NewGuid():N}"[..20];
|
var giver = await _host.SignUp("giver");
|
||||||
var first = new StringWriter();
|
var taker = await _host.SignUp("taker");
|
||||||
var second = new StringWriter();
|
var persona = await _host.Persona(giver, "moved");
|
||||||
|
var token = await _host.MastodonToken(persona);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, await VerifyCredentials(token));
|
||||||
|
|
||||||
Assert.Equal(0, await AdminCommands.Run(new[] { "smoke", persona }, scope.ServiceProvider, output: first));
|
Assert.Equal(0, await AdminCommands.Run(new[] { "move-persona", "@" + persona.UserName, taker.UserName.ToUpperInvariant() }, scope.ServiceProvider, output: new StringWriter()));
|
||||||
Assert.Equal(0, await AdminCommands.Run(new[] { "smoke", persona }, scope.ServiceProvider, output: second));
|
Assert.Equal(0, await AdminCommands.Run(new[] { "move-persona", persona.UserName, taker.UserName }, scope.ServiceProvider, output: new StringWriter()));
|
||||||
|
|
||||||
var (login, oldPassword) = (first.ToString().Trim().Split(' ')[0], first.ToString().Trim().Split(' ')[1]);
|
Assert.Equal(HttpStatusCode.Unauthorized, await VerifyCredentials(token));
|
||||||
var newPassword = second.ToString().Trim().Split(' ')[1];
|
Assert.DoesNotContain(persona.Id, await PersonaIds(giver));
|
||||||
Assert.Equal(AdminCommands.SmokeLogin, login);
|
Assert.Contains(persona.Id, await PersonaIds(taker));
|
||||||
Assert.NotEqual(oldPassword, newPassword);
|
Assert.Equal(1, await AdminCommands.Run(new[] { "move-persona", $"nobody{Guid.NewGuid():N}"[..20], taker.UserName }, scope.ServiceProvider));
|
||||||
Assert.Equal(HttpStatusCode.OK, await LogIn(login, newPassword));
|
Assert.Equal(1, await AdminCommands.Run(new[] { "move-persona", persona.UserName, $"nobody{Guid.NewGuid():N}" }, scope.ServiceProvider));
|
||||||
Assert.NotEqual(HttpStatusCode.OK, await LogIn(login, oldPassword));
|
}
|
||||||
var avatars = await DB.Default.Find<Avatar>().Match(a => a.UserName == persona).ExecuteAsync(TestContext.Current.CancellationToken);
|
|
||||||
var avatar = Assert.Single(avatars);
|
[Fact]
|
||||||
Assert.False(avatar.Settings.IsDiscoverable);
|
public async Task Delete_root_deletes_a_login_with_the_personas_it_still_has()
|
||||||
Assert.False(avatar.Settings.IsIndexable);
|
{
|
||||||
|
var cancellation = TestContext.Current.CancellationToken;
|
||||||
|
using var scope = _host.Services.CreateScope();
|
||||||
|
var root = await _host.SignUp("leaving");
|
||||||
|
var persona = await _host.Persona(root, "leaving");
|
||||||
|
var output = new StringWriter();
|
||||||
|
|
||||||
|
Assert.Equal(0, await AdminCommands.Run(new[] { "delete-root", root.UserName }, scope.ServiceProvider, output: output));
|
||||||
|
Assert.Equal(1, await AdminCommands.Run(new[] { "delete-root", root.UserName }, scope.ServiceProvider, output: new StringWriter()));
|
||||||
|
|
||||||
|
Assert.Contains("@" + persona.UserName, output.ToString());
|
||||||
|
Assert.NotEqual(HttpStatusCode.OK, await LogIn(root.UserName, root.Password));
|
||||||
|
Assert.NotNull((await DB.Default.Find<RootUser>().OneAsync(root.Id, cancellation)).DeletedAt);
|
||||||
|
Assert.NotNull((await DB.Default.Find<Avatar>().OneAsync(persona.Id, cancellation)).DeletionAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,5 +246,21 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
}
|
}
|
||||||
Assert.Equal(new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc), (await DB.Default.Find<Avatar>().OneAsync(dated.ID, token)).PublishedOn);
|
Assert.Equal(new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc), (await DB.Default.Find<Avatar>().OneAsync(dated.ID, token)).PublishedOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_server_with_an_administrator_is_set_up()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
await DB.Default.DeleteAsync<PrivaPub.Models.ServerSetup>(PrivaPub.Models.ServerSetup.Name);
|
||||||
|
var admin = new RootUser { UserName = $"admin{Guid.NewGuid():N}"[..20] };
|
||||||
|
admin.SetAdmin(true);
|
||||||
|
await DB.Default.SaveAsync(admin, token);
|
||||||
|
Assert.True(await PrivaPub.Models.ServerSetup.IsPending(token));
|
||||||
|
|
||||||
|
await new _018_a_server_with_an_administrator_is_set_up().UpgradeAsync();
|
||||||
|
|
||||||
|
Assert.False(await PrivaPub.Models.ServerSetup.IsPending(token));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.RateLimiting;
|
|||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.ClientModels;
|
using PrivaPub.ClientModels;
|
||||||
using PrivaPub.ClientModels.Resources;
|
using PrivaPub.ClientModels.Resources;
|
||||||
using PrivaPub.ClientModels.Group;
|
using PrivaPub.ClientModels.Group;
|
||||||
@@ -135,13 +137,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
return StatusCode(result.StatusCode, result);
|
return StatusCode(result.StatusCode, result);
|
||||||
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||||
|
|
||||||
var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
var avatar = await InsertPersona(user.ID, avatarUserName, signUpForm.AvatarName);
|
||||||
{
|
|
||||||
RootId = user.ID,
|
|
||||||
Name = string.IsNullOrWhiteSpace(signUpForm.AvatarName) ? avatarUserName : signUpForm.AvatarName.Trim(),
|
|
||||||
UserName = avatarUserName,
|
|
||||||
Biography = string.Empty
|
|
||||||
});
|
|
||||||
if (!avatar.IsValid)
|
if (!avatar.IsValid)
|
||||||
return StatusCode(avatar.StatusCode, avatar);
|
return StatusCode(avatar.StatusCode, avatar);
|
||||||
|
|
||||||
@@ -190,13 +186,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
var personaProblem = await PersonaProblem(avatarUserName, user.UserName, token);
|
var personaProblem = await PersonaProblem(avatarUserName, user.UserName, token);
|
||||||
if (personaProblem != default)
|
if (personaProblem != default)
|
||||||
return BadRequest(new WebResult().Invalidate(personaProblem));
|
return BadRequest(new WebResult().Invalidate(personaProblem));
|
||||||
var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
var inserted = await InsertPersona(user.ID, avatarUserName, loginForm.AvatarName);
|
||||||
{
|
|
||||||
RootId = user.ID,
|
|
||||||
Name = string.IsNullOrWhiteSpace(loginForm.AvatarName) ? avatarUserName : loginForm.AvatarName.Trim(),
|
|
||||||
UserName = avatarUserName,
|
|
||||||
Biography = string.Empty
|
|
||||||
});
|
|
||||||
if (!inserted.IsValid)
|
if (!inserted.IsValid)
|
||||||
return StatusCode(inserted.StatusCode, inserted);
|
return StatusCode(inserted.StatusCode, inserted);
|
||||||
avatar = (ViewAvatar)inserted.Data;
|
avatar = (ViewAvatar)inserted.Data;
|
||||||
@@ -220,6 +210,77 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet, Route("/clientapi/user/setup"), AllowAnonymous]
|
||||||
|
public async Task<IActionResult> Setup(CancellationToken token) =>
|
||||||
|
Ok(new ViewServerSetup { IsPending = await ServerSetup.IsPending(token) });
|
||||||
|
|
||||||
|
// The server's first login, made while it has no administrator (owner decision 2026-10-16: first come, first served):
|
||||||
|
// it is made the administrator, with its first persona, and setup closes for good. Sign-up stays closed to everyone
|
||||||
|
// else (Registrations), so NodeInfo and the instance API don't change.
|
||||||
|
[HttpPost, Route("/clientapi/user/setup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
||||||
|
public async Task<IActionResult> SetUp(SetupForm setupForm, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
|
||||||
|
var result = new WebResult();
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||||
|
var claimed = false;
|
||||||
|
var adminId = default(string);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await ServerSetup.IsPending(token))
|
||||||
|
return SetUpAlready();
|
||||||
|
|
||||||
|
var avatarUserName = setupForm.AvatarUserName?.Trim().ToLowerInvariant();
|
||||||
|
var personaProblem = await PersonaProblem(avatarUserName, setupForm.UserName, token);
|
||||||
|
if (personaProblem != default)
|
||||||
|
return BadRequest(result.Invalidate(personaProblem));
|
||||||
|
|
||||||
|
claimed = await ServerSetup.Claim(token);
|
||||||
|
if (!claimed)
|
||||||
|
return SetUpAlready();
|
||||||
|
|
||||||
|
result = await UsersService.SignUpAsync(setupForm);
|
||||||
|
if (!result.IsValid)
|
||||||
|
{
|
||||||
|
await ServerSetup.Release();
|
||||||
|
return StatusCode(result.StatusCode, result);
|
||||||
|
}
|
||||||
|
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||||
|
user.SetAdmin(true);
|
||||||
|
await DB.Default.SaveAsync(user, CancellationToken.None);
|
||||||
|
adminId = user.ID;
|
||||||
|
await ServerSetup.Claimed(adminId, CancellationToken.None);
|
||||||
|
|
||||||
|
// set up from here, whatever happens to the persona: it can be made in the client's settings
|
||||||
|
var avatar = await InsertPersona(user.ID, avatarUserName, setupForm.AvatarName);
|
||||||
|
if (!avatar.IsValid)
|
||||||
|
return StatusCode(avatar.StatusCode, avatar);
|
||||||
|
|
||||||
|
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(SetUp)}()");
|
||||||
|
if (claimed && adminId == default)
|
||||||
|
await ServerSetup.Release();
|
||||||
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IActionResult SetUpAlready() =>
|
||||||
|
StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
new WebResult().Invalidate(Localizer["This server is already set up: joining it takes a group invitation."], StatusCodes.Status403Forbidden));
|
||||||
|
|
||||||
|
Task<WebResult> InsertPersona(string rootId, string avatarUserName, string avatarName) =>
|
||||||
|
AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
||||||
|
{
|
||||||
|
RootId = rootId,
|
||||||
|
Name = string.IsNullOrWhiteSpace(avatarName) ? avatarUserName : avatarName.Trim(),
|
||||||
|
UserName = avatarUserName,
|
||||||
|
Biography = string.Empty
|
||||||
|
});
|
||||||
|
|
||||||
async Task<string> PersonaProblem(string avatarUserName, string rootUserName, CancellationToken token)
|
async Task<string> PersonaProblem(string avatarUserName, string rootUserName, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(avatarUserName))
|
if (string.IsNullOrEmpty(avatarUserName))
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ namespace PrivaPub.Infrastructure.Backup
|
|||||||
[nameof(MaintenanceLock)] = "who is backing up or restoring now",
|
[nameof(MaintenanceLock)] = "who is backing up or restoring now",
|
||||||
["AppConfiguration"] = "the configuration's copy holds the SMTP password, and is made again from appsettings at boot",
|
["AppConfiguration"] = "the configuration's copy holds the SMTP password, and is made again from appsettings at boot",
|
||||||
[nameof(RestoreRecord)] = "what restores did outlives what they restore",
|
[nameof(RestoreRecord)] = "what restores did outlives what they restore",
|
||||||
["PersonaArchive"] = "personas' archives being made or imported belong to the moment"
|
["PersonaArchive"] = "personas' archives being made or imported belong to the moment",
|
||||||
|
["ServerSetup"] = "the server is set up once: a restore never opens its setup again"
|
||||||
};
|
};
|
||||||
|
|
||||||
static readonly JsonWriterSettings Json = new() { OutputMode = JsonOutputMode.CanonicalExtendedJson, Indent = false };
|
static readonly JsonWriterSettings Json = new() { OutputMode = JsonOutputMode.CanonicalExtendedJson, Indent = false };
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using OpenIddict.Abstractions;
|
||||||
|
|
||||||
using PrivaPub.ClientModels;
|
using PrivaPub.ClientModels;
|
||||||
using PrivaPub.ClientModels.User;
|
using PrivaPub.ClientModels.User;
|
||||||
using PrivaPub.ClientModels.User.Avatar;
|
|
||||||
using PrivaPub.Infrastructure.Backup;
|
using PrivaPub.Infrastructure.Backup;
|
||||||
|
using PrivaPub.Models;
|
||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
using PrivaPub.Services;
|
using PrivaPub.Services;
|
||||||
using PrivaPub.Services.ClientToServer.Private;
|
|
||||||
using PrivaPub.StaticServices;
|
|
||||||
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Cryptography;
|
|
||||||
|
|
||||||
namespace PrivaPub.Infrastructure.Cli
|
namespace PrivaPub.Infrastructure.Cli
|
||||||
{
|
{
|
||||||
// `PrivaPub admin ...` runs with the whole server built but not started: no Kestrel, no hosted services.
|
// `PrivaPub admin ...` runs with the whole server built but not started: no Kestrel, no hosted services.
|
||||||
public static class AdminCommands
|
public static class AdminCommands
|
||||||
{
|
{
|
||||||
public const string SmokeLogin = "deploy-smoke";
|
|
||||||
|
|
||||||
const string Usage = """
|
const string Usage = """
|
||||||
usage: PrivaPub admin promote|demote <root>
|
usage: PrivaPub admin promote|demote <root>
|
||||||
PrivaPub admin create-root <login> [--admin] the password is read from standard input
|
PrivaPub admin create-root <login> [--admin] the password is read from standard input
|
||||||
PrivaPub admin smoke <persona> prints "<login> <password>" for the deploy's signed-in check
|
PrivaPub admin move-persona <persona> <root> gives a persona to another login; its Mastodon tokens end
|
||||||
|
PrivaPub admin delete-root <root> deletes a login and every persona it still has
|
||||||
PrivaPub admin media audit [--fix] media files against what holds them; --fix (as www-data) trashes
|
PrivaPub admin media audit [--fix] media files against what holds them; --fix (as www-data) trashes
|
||||||
what nothing holds and gives today's pictures their rows
|
what nothing holds and gives today's pictures their rows
|
||||||
PrivaPub admin backup [--kind manual|pre-deploy] [--db-only]
|
PrivaPub admin backup [--kind manual|pre-deploy] [--db-only]
|
||||||
@@ -50,8 +48,10 @@ namespace PrivaPub.Infrastructure.Cli
|
|||||||
return await Promote(verb == "promote", userName, output);
|
return await Promote(verb == "promote", userName, output);
|
||||||
case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"):
|
case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"):
|
||||||
return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output);
|
return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output);
|
||||||
case ["smoke", var persona]:
|
case ["move-persona", var persona, var root]:
|
||||||
return await Smoke(services, persona, output);
|
return await MovePersona(services, persona, root, output);
|
||||||
|
case ["delete-root", var root]:
|
||||||
|
return await DeleteRoot(services, root, output);
|
||||||
case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"):
|
case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"):
|
||||||
return await AuditMedia(services, flags.Contains("--fix"), output);
|
return await AuditMedia(services, flags.Contains("--fix"), output);
|
||||||
case ["backup", "verify", var id]:
|
case ["backup", "verify", var id]:
|
||||||
@@ -194,19 +194,17 @@ namespace PrivaPub.Infrastructure.Cli
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Policies.RemoveAll(p => p is Policies.IsAdmin or Policies.IsModerator);
|
user.SetAdmin(promote);
|
||||||
if (promote)
|
|
||||||
user.Policies.AddRange(new[] { Policies.IsAdmin, Policies.IsModerator });
|
|
||||||
if (!user.Policies.Contains(Policies.IsUser))
|
|
||||||
user.Policies.Add(Policies.IsUser);
|
|
||||||
user.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await DB.Default.SaveAsync(user);
|
await DB.Default.SaveAsync(user);
|
||||||
|
if (promote)
|
||||||
|
await ServerSetup.Seal(user.ID, default);//an administrator exists: the client's setup is closed
|
||||||
|
|
||||||
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
|
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// With sign-up closed this is how the first root is made; group invitations make the rest.
|
// With sign-up closed, this or the client's setup (while no administrator exists) makes the first root; group invitations
|
||||||
|
// make the rest.
|
||||||
static async Task<int> CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output)
|
static async Task<int> CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output)
|
||||||
{
|
{
|
||||||
var form = new LoginForm { UserName = login, Password = password?.Trim() };
|
var form = new LoginForm { UserName = login, Password = password?.Trim() };
|
||||||
@@ -231,58 +229,64 @@ namespace PrivaPub.Infrastructure.Cli
|
|||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The deploy's signed-in smoke check: a root nobody signs in to by hand, owning one undiscoverable persona. Every run
|
// A persona given to another login (owner decision 2026-10-17, made for @thepra): its link moves first, so the old
|
||||||
// sets a new password and prints it, so no secret is kept anywhere and nothing waits on a person.
|
// login can no longer exchange a token for it, then its Mastodon tokens and authorizations end. Its actor, posts and
|
||||||
static async Task<int> Smoke(IServiceProvider services, string personaName, TextWriter output)
|
// followers stay as they are, and nobody outside is told: nothing they hold changes.
|
||||||
|
static async Task<int> MovePersona(IServiceProvider services, string personaName, string login, TextWriter output)
|
||||||
{
|
{
|
||||||
personaName = personaName.ToLowerInvariant();
|
personaName = personaName.TrimStart('@').ToLowerInvariant();
|
||||||
var password = "Smoke-x" + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(24));
|
login = login.ToLowerInvariant();
|
||||||
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
|
var persona = await DB.Default.Find<Avatar>().Match(a => a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync();
|
||||||
if (root == default)
|
|
||||||
{
|
|
||||||
var created = await services.GetRequiredService<IRootUsersService>().SignUpAsync(new LoginForm { UserName = SmokeLogin, Password = password });
|
|
||||||
if (!created.IsValid)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(created.ErrorMessage);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
|
|
||||||
}
|
|
||||||
else if (root.DeletedAt.HasValue || root.IsBanned)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"the root '{SmokeLogin}' is deleted or banned");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
await DB.Default.Update<RootUser>().MatchID(root.ID)
|
|
||||||
.Modify(u => u.HashedPassword, services.GetRequiredService<IPasswordHasher>().Hash(password))
|
|
||||||
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
|
||||||
.ExecuteAsync();
|
|
||||||
|
|
||||||
var owned = (await DB.Default.Find<RootToAvatar>().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList();
|
|
||||||
var persona = await DB.Default.Find<Avatar>().Match(a => owned.Contains(a.ID) && a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync();
|
|
||||||
if (persona == default)
|
if (persona == default)
|
||||||
{
|
{
|
||||||
var inserted = await services.GetRequiredService<IPrivateAvatarUsersService>().InsertAvatar(new InsertAvatarForm
|
Console.Error.WriteLine($"no persona '@{personaName}'");
|
||||||
{
|
return 1;
|
||||||
RootId = root.ID,
|
}
|
||||||
UserName = personaName,
|
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login).ExecuteFirstAsync();
|
||||||
Name = personaName,
|
if (root == default || root.DeletedAt.HasValue || root.IsBanned)
|
||||||
Biography = "The deploy signs in here to check that the Mastodon API works for a signed-in persona."
|
{
|
||||||
});
|
Console.Error.WriteLine($"no root user '{login}' that can take it");
|
||||||
if (!inserted.IsValid)
|
return 1;
|
||||||
{
|
}
|
||||||
Console.Error.WriteLine(inserted.ErrorMessage);
|
var link = await DB.Default.Find<RootToAvatar>().Match(ra => ra.AvatarId == persona.ID).ExecuteFirstAsync();
|
||||||
return 1;
|
if (link == default)
|
||||||
}
|
{
|
||||||
persona = await DB.Default.Find<Avatar>().MatchID(((ViewAvatar)inserted.Data).Id).ExecuteFirstAsync();
|
Console.Error.WriteLine($"'@{personaName}' belongs to no login");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (link.RootId == root.ID)
|
||||||
|
{
|
||||||
|
output.WriteLine($"@{personaName}: already {login}'s");
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
await DB.Default.Update<Avatar>().MatchID(persona.ID)
|
|
||||||
.Modify(a => a.Settings.IsDiscoverable, false)
|
|
||||||
.Modify(a => a.Settings.IsIndexable, false)
|
|
||||||
.ExecuteAsync();
|
|
||||||
|
|
||||||
output.WriteLine($"{SmokeLogin} {password}");
|
await DB.Default.Update<RootToAvatar>().MatchID(link.ID).Modify(ra => ra.RootId, root.ID).ExecuteAsync();
|
||||||
|
await services.GetRequiredService<IOpenIddictTokenManager>().RevokeBySubjectAsync(persona.ID);
|
||||||
|
await services.GetRequiredService<IOpenIddictAuthorizationManager>().RevokeBySubjectAsync(persona.ID);
|
||||||
|
output.WriteLine($"@{personaName}: moved to {login}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A login deleted from the box as the client deletes its own (RootRemoval): every persona it still has goes with it,
|
||||||
|
// announced deleted, and its sessions end.
|
||||||
|
static async Task<int> DeleteRoot(IServiceProvider services, string login, TextWriter output)
|
||||||
|
{
|
||||||
|
login = login.ToLowerInvariant();
|
||||||
|
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null).ExecuteFirstAsync();
|
||||||
|
if (root == default)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"no root user '{login}'");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
var owned = (await DB.Default.Find<RootToAvatar>().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList();
|
||||||
|
var personas = (await DB.Default.Find<Avatar>().Match(a => owned.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync())
|
||||||
|
.Select(a => "@" + a.UserName).ToList();
|
||||||
|
if (!await services.GetRequiredService<IRootRemoval>().Remove(root.ID, default))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"'{login}' could not be deleted");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
output.WriteLine($"{login}: deleted{(personas.Count > 0 ? ", with " + string.Join(", ", personas) : string.Empty)}");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
|
||||||
|
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
// the client's setup is open only while no administrator exists (ServerSetup): a server that already has one, made with
|
||||||
|
// the CLI before setup existed, is set up
|
||||||
|
public class _018_a_server_with_an_administrator_is_set_up : IMigration
|
||||||
|
{
|
||||||
|
public async Task UpgradeAsync()
|
||||||
|
{
|
||||||
|
var admin = await DB.Default.Find<RootUser>()
|
||||||
|
.Match(u => u.Policies.Contains(Policies.IsAdmin))
|
||||||
|
.Sort(u => u.CreatedAt, Order.Ascending)
|
||||||
|
.ExecuteFirstAsync();
|
||||||
|
if (admin != default)
|
||||||
|
await ServerSetup.Seal(admin.ID, default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
namespace PrivaPub.Models
|
||||||
|
{
|
||||||
|
// The server's first-time setup (owner decision 2026-10-16): while no administrator exists, the client's setup page makes
|
||||||
|
// the first login, which becomes the administrator; this document then closes setup for good, whoever is demoted or
|
||||||
|
// deleted later (the CLI stays the way back). The first request to insert it wins, so two setups at once make one
|
||||||
|
// administrator. A backup never holds it and a restore never drops it (ServerBackup.Excluded): a restore tombstones an
|
||||||
|
// administrator made after the backup, and setup would otherwise be open to whoever came first.
|
||||||
|
public class ServerSetup : Entity
|
||||||
|
{
|
||||||
|
public const string Name = "setup";
|
||||||
|
|
||||||
|
public string AdminId { get; set; }//null while the claiming request is still making its login
|
||||||
|
public DateTime SealedAt { get; set; }
|
||||||
|
|
||||||
|
public static async Task<bool> IsPending(CancellationToken token) =>
|
||||||
|
!await DB.Default.Find<ServerSetup>().Match(s => s.ID == Name).ExecuteAnyAsync(token);
|
||||||
|
|
||||||
|
/// <summary>Takes the setup for one request: false when it is done, or another request took it first.</summary>
|
||||||
|
public static async Task<bool> Claim(CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DB.Default.InsertAsync(new ServerSetup { ID = Name, SealedAt = DateTime.UtcNow }, token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Names the administrator the claiming request made.</summary>
|
||||||
|
public static Task Claimed(string adminId, CancellationToken token) =>
|
||||||
|
DB.Default.Update<ServerSetup>().Match(s => s.ID == Name && s.AdminId == null).Modify(s => s.AdminId, adminId).ExecuteAsync(token);
|
||||||
|
|
||||||
|
/// <summary>Gives a claim back when its login couldn't be made; a named administrator is never given back.</summary>
|
||||||
|
public static Task Release() =>
|
||||||
|
DB.Default.DeleteAsync<ServerSetup>(s => s.ID == Name && s.AdminId == null);//a delete takes no token outside a transaction
|
||||||
|
|
||||||
|
/// <summary>Closes setup for an administrator made elsewhere (the CLI, a server that had one already).</summary>
|
||||||
|
public static async Task Seal(string adminId, CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DB.Default.Update<ServerSetup>().Match(s => s.ID == Name)
|
||||||
|
.Modify(b => b.SetOnInsert(s => s.AdminId, adminId))
|
||||||
|
.Modify(b => b.SetOnInsert(s => s.SealedAt, DateTime.UtcNow))
|
||||||
|
.Option(o => o.IsUpsert = true)
|
||||||
|
.ExecuteAsync(token);
|
||||||
|
}
|
||||||
|
catch (MongoCommandException ex) when (ex.Code == 11000)
|
||||||
|
{
|
||||||
|
//sealed meanwhile
|
||||||
|
}
|
||||||
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||||
|
{
|
||||||
|
//sealed meanwhile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,17 @@ namespace PrivaPub.Models.User
|
|||||||
public DateTime? DeletedAt { get; set; }
|
public DateTime? DeletedAt { get; set; }
|
||||||
public DateTime? CredentialsChangedAt { get; set; }//when RootSessions last ended every session
|
public DateTime? CredentialsChangedAt { get; set; }//when RootSessions last ended every session
|
||||||
public string SessionStamp { get; set; }//carried by every /clientapi token; a new one ends the tokens issued before
|
public string SessionStamp { get; set; }//carried by every /clientapi token; a new one ends the tokens issued before
|
||||||
|
|
||||||
|
/// <summary>An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user.</summary>
|
||||||
|
public void SetAdmin(bool admin)
|
||||||
|
{
|
||||||
|
Policies.RemoveAll(p => p is ClientModels.Policies.IsAdmin or ClientModels.Policies.IsModerator);
|
||||||
|
if (admin)
|
||||||
|
Policies.AddRange([ClientModels.Policies.IsAdmin, ClientModels.Policies.IsModerator]);
|
||||||
|
if (!Policies.Contains(ClientModels.Policies.IsUser))
|
||||||
|
Policies.Add(ClientModels.Policies.IsUser);
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ContactItem
|
public class ContactItem
|
||||||
|
|||||||
+6
-3
@@ -82,7 +82,10 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
|||||||
it is kept, and a bare `Lock` from the post's server is taken (Mbin). A direct message goes out as a `ChatMessage`
|
it is kept, and a bare `Lock` from the post's server is taken (Mbin). A direct message goes out as a `ChatMessage`
|
||||||
to someone who writes to us that way, and to Lemmy 0.19 and Mbin as NodeInfo names them (owner decision, G-0008
|
to someone who writes to us that way, and to Lemmy 0.19 and Mbin as NodeInfo names them (owner decision, G-0008
|
||||||
closed).
|
closed).
|
||||||
- [ ] P7 Threads, communities, moderation, the social graph
|
- [ ] P7 Threads, communities, moderation, the social graph: in progress; v1.24.0 deployed 2026-10-08 (Lemmy's `Warn` and
|
||||||
|
`Resolve`, flairs, FEP-268d `searchableBy`, thread backfill with `contextHistory` and ETags, feeds read and followed
|
||||||
|
through `privapub_feeds`, PieFed's post `Move`, downvotes out). Left: featured and moderators listings, link posts out,
|
||||||
|
community polls, the outbound shape Lemmy requires
|
||||||
- [ ] P8 Signatures, discovery, the long tail
|
- [ ] P8 Signatures, discovery, the long tail
|
||||||
- [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts,
|
- [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts,
|
||||||
followed hashtags, trends and a directory (the server side, 2026-10-05; decePub's screens follow)
|
followed hashtags, trends and a directory (the server side, 2026-10-05; decePub's screens follow)
|
||||||
@@ -244,8 +247,8 @@ and circles (see Owner decisions).
|
|||||||
| What runs in production | **Everything that is built is on and checked by the deploy**, and nothing waits on a person running a command. |
|
| What runs in production | **Everything that is built is on and checked by the deploy**, and nothing waits on a person running a command. |
|
||||||
| Geolocation | **The server fetches DB-IP Lite itself** (`GeoUpdater`): it checks daily, installs a new month's databases once they are published, refuses a file that does not open as the right kind of database, and keeps the old one when anything fails. No timer and no root step. The deploy fails if the databases are missing or more than 40 days old. |
|
| Geolocation | **The server fetches DB-IP Lite itself** (`GeoUpdater`): it checks daily, installs a new month's databases once they are published, refuses a file that does not open as the right kind of database, and keeps the old one when anything fails. No timer and no root step. The deploy fails if the databases are missing or more than 40 days old. |
|
||||||
| Crawler | **On in production**, seeded with a handful of large servers of different kinds (`appsettings.Production.json`); the deploy fails if `/stargazing` does not say it is on. |
|
| Crawler | **On in production**, seeded with a handful of large servers of different kinds (`appsettings.Production.json`); the deploy fails if `/stargazing` does not say it is on. |
|
||||||
| Sign-up | **Closed: invitations only.** A group invitation creates an account; open sign-up answers 403. NodeInfo, `/api/v1/instance` and `/api/v2/instance` read the same switch (`Registrations:Mode`), and the deploy fails if they disagree. The first login on a server is made with `PrivaPub admin create-root`. |
|
| Sign-up | **Closed: invitations only.** A group invitation creates an account; open sign-up answers 403. NodeInfo, `/api/v1/instance` and `/api/v2/instance` read the same switch (`Registrations:Mode`), and the deploy fails if they disagree. The first login on a server is made from the client's setup page while no administrator exists (2026-10-16: first come, first served, no setup code); it becomes the administrator with its first persona, and setup closes for good (`ServerSetup`, kept across restores). `PrivaPub admin create-root` remains the way in from the box. |
|
||||||
| Signed-in smoke check | **`@thepra`, undiscoverable, made and kept by the deploy**: `PrivaPub admin smoke thepra` creates or keeps the root `deploy-smoke` and the persona and gives the root a new password on every deploy; the deploy signs in through the real OAuth flow, checks the signed-in API and revokes its token. No secret is stored. |
|
| Signed-in smoke check | **Dropped (2026-10-17).** It was `@thepra`, undiscoverable, made and kept by the deploy under the root `deploy-smoke`. The owner took `@thepra` for the administrator's login (`PrivaPub admin move-persona thepra <root>`), `deploy-smoke` was deleted (`admin delete-root`), and the deploy keeps only its anonymous checks. |
|
||||||
| Signed fetches (SecureMode) | **On in production** once the pasture passes with it on (Phase 2 of the 2026-10-04 plan). |
|
| Signed fetches (SecureMode) | **On in production** once the pasture passes with it on (Phase 2 of the 2026-10-04 plan). |
|
||||||
| Circles on Mastodon and GoToSocial | **Each member's copy names that member** in `cc`; a member's refetch names the member, an instance actor's refetch the members on its server. Nothing new is revealed to anyone outside the circle. |
|
| Circles on Mastodon and GoToSocial | **Each member's copy names that member** in `cc`; a member's refetch names the member, an instance actor's refetch the members on its server. Nothing new is revealed to anyone outside the circle. |
|
||||||
| What circles and located posts reveal | **Unchanged**: circles still answer WebFinger, and circle and located posts still count in a persona's post count, "a good balance for the fediverse to work". |
|
| What circles and located posts reveal | **Unchanged**: circles still answer WebFinger, and circle and located posts still count in a persona's post count, "a good balance for the fediverse to work". |
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ privapub_root() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# privapub_token <persona>: creates the persona under the pasture root if needed and returns a Mastodon token for it,
|
# privapub_token <persona>: creates the persona under the pasture root if needed and returns a Mastodon token for it,
|
||||||
# through the same OAuth flow the deploy's smoke check uses (tools/smoke/oauth.sh).
|
# through the real OAuth flow (tools/smoke/oauth.sh).
|
||||||
privapub_token() {
|
privapub_token() {
|
||||||
local persona=$1 jwt
|
local persona=$1 jwt
|
||||||
jwt=$(privapub_root)
|
jwt=$(privapub_root)
|
||||||
|
|||||||
Reference in new issue
Block a user