diff --git a/README.md b/README.md index a8ffa5b2..2695e2eb 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ mkdir -p ~/archivebox/data && cd ~/archivebox curl -fsSL 'https://docker-compose.archivebox.io' > docker-compose.yml docker compose pull docker compose up -d --wait # initializes new collections automatically -docker compose exec archivebox archivebox manage createsuperuser # create the first Web UI user +# open http://admin.archivebox.localhost:8000 to finish setup # docker compose run --rm archivebox add 'https://example.com' # docker compose run --rm archivebox help
@@ -82,8 +82,8 @@ docker compose exec archivebox archivebox manage createsuperuser # crea mkdir -p ~/archivebox/data && cd ~/archivebox/data docker run --rm -it -v "$PWD:/data" archivebox/archivebox:dev init docker run --rm -it -v "$PWD:/data" archivebox/archivebox:dev install -docker run --rm -it -v "$PWD:/data" archivebox/archivebox:dev manage createsuperuser docker run -d --name archivebox -v "$PWD:/data" -p 8000:8000 archivebox/archivebox:dev +# open http://admin.archivebox.localhost:8000 to finish setup # docker run -it -v $PWD:/data archivebox/archivebox:dev add 'https://example.com' # docker run -it -v $PWD:/data archivebox/archivebox:dev help
@@ -186,11 +186,10 @@ ArchiveBox is free for everyone to self-host, but we also provide support, secur curl -fsSL 'https://docker-compose.archivebox.io' > docker-compose.yml docker compose pull -
  • Start the server, which initializes a new collection automatically, then create the first admin user. +
  • Start the server, which initializes a new collection automatically.
    docker compose up -d --wait
    -docker compose exec archivebox archivebox manage createsuperuser
     
  • -
  • Next steps: Log in to the Admin UI at http://admin.archivebox.localhost:8000. +
  • Open http://admin.archivebox.localhost:8000 and follow the setup wizard to create the first admin and configure web access.
    
     # run CLI commands inside the server container started above
     docker compose exec archivebox archivebox add 'https://example.com'
    @@ -271,9 +270,8 @@ archivebox init     # initialize a new collection
     archivebox install  # install all the runtime dependencies (e.g. chrome, single-file, yt-dlp, etc.)
     
  • -
  • Create an admin account, then optionally start the server and log in to the Admin UI at http://admin.archivebox.localhost:8000. -
    archivebox manage createsuperuser
    -archivebox server 0.0.0.0:8000
    +
  • Optionally start the server, then open the Admin UI at http://admin.archivebox.localhost:8000 and follow the same setup wizard used by Docker installs. +
    archivebox server 0.0.0.0:8000
     # completely optional, CLI can always be used without running a server
     # archivebox [subcommand] [--help]
     archivebox help
    @@ -308,9 +306,8 @@ archivebox add 'https://example.com'
     

  • -
  • Create an admin account, then optionally start the server and log in to the Admin UI at http://admin.archivebox.localhost:8000. -
    archivebox manage createsuperuser
    -archivebox server 0.0.0.0:8000
    +
  • Optionally start the server, then open the Admin UI at http://admin.archivebox.localhost:8000 and follow the same setup wizard used by Docker installs. +
    archivebox server 0.0.0.0:8000
     # completely optional, CLI can always be used without running a server
     # archivebox [subcommand] [--help]
     archivebox help
    @@ -342,9 +339,8 @@ archivebox init
     archivebox install
     
  • -
  • Create an admin account, then optionally start the server and log in to the Admin UI at http://admin.archivebox.localhost:8000. -
    archivebox manage createsuperuser
    -archivebox server 0.0.0.0:8000
    +
  • Optionally start the server, then open the Admin UI at http://admin.archivebox.localhost:8000 and follow the same setup wizard used by Docker installs. +
    archivebox server 0.0.0.0:8000
     # completely optional, CLI can always be used without running a server
     # archivebox [subcommand] [--help]
     archivebox help
    diff --git a/archivebox/core/admin_site.py b/archivebox/core/admin_site.py
    index e87c88a8..665653b8 100644
    --- a/archivebox/core/admin_site.py
    +++ b/archivebox/core/admin_site.py
    @@ -12,11 +12,13 @@ from admin_data_views.admin import (
         get_app_list as adv_get_app_list,
     )
     from django.contrib import admin
    -from django.contrib.auth import REDIRECT_FIELD_NAME
    +from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model, login as auth_login
     from django.contrib.auth.decorators import login_not_required
    +from django.contrib.auth.forms import UserCreationForm
     from django.contrib.auth.views import LoginView
    -from django.db import DatabaseError, connection
    +from django.db import DatabaseError, connection, transaction
     from django.http import HttpResponseRedirect
    +from django.template.response import TemplateResponse
     from django.urls import reverse
     from django.utils.decorators import method_decorator
     from django.utils.translation import gettext as _
    @@ -103,12 +105,15 @@ class ArchiveBoxAdmin(admin.AdminSite):
     
             from django.contrib.admin.forms import AdminAuthenticationForm
     
    +        User = get_user_model()
    +        first_admin_setup = not User.objects.filter(is_superuser=True).exclude(username="system").exists()
             context = {
                 **self.each_context(request),
    -            "title": _("Log in"),
    +            "title": _("Set up ArchiveBox") if first_admin_setup else _("Log in"),
                 "subtitle": None,
                 "app_path": request.get_full_path(),
                 "username": request.user.get_username(),
    +            "first_admin_setup": first_admin_setup,
             }
             if REDIRECT_FIELD_NAME not in request.GET and REDIRECT_FIELD_NAME not in request.POST:
                 context[REDIRECT_FIELD_NAME] = reverse("admin:index", current_app=self.name)
    @@ -116,6 +121,30 @@ class ArchiveBoxAdmin(admin.AdminSite):
     
             index_path = reverse("admin:index", current_app=self.name)
             request.current_app = self.name
    +        if first_admin_setup:
    +            form = UserCreationForm(request.POST or None)
    +            if request.method == "POST" and form.is_valid():
    +                with transaction.atomic():
    +                    # Fresh collections contain the system superuser, which also
    +                    # provides a row lock while the first real admin is created.
    +                    list(User.objects.select_for_update().filter(is_superuser=True).values_list("pk", flat=True))
    +                    if User.objects.filter(is_superuser=True).exclude(username="system").exists():
    +                        form.add_error(None, _("An admin account was already created. Log in instead."))
    +                    else:
    +                        user = form.save(commit=False)
    +                        user.is_staff = True
    +                        user.is_superuser = True
    +                        user.save()
    +                        auth_login(request, user, backend="django.contrib.auth.backends.ModelBackend")
    +                        return HttpResponseRedirect(index_path)
    +
    +            context["form"] = form
    +            return TemplateResponse(
    +                request,
    +                self.login_template or "admin/login.html",
    +                context,
    +            )
    +
             return ArchiveBoxLoginView.as_view(
                 extra_context=context,
                 authentication_form=self.login_form or AdminAuthenticationForm,
    diff --git a/archivebox/core/templatetags/core_tags.py b/archivebox/core/templatetags/core_tags.py
    index 30ecd6ee..123c151d 100644
    --- a/archivebox/core/templatetags/core_tags.py
    +++ b/archivebox/core/templatetags/core_tags.py
    @@ -438,16 +438,6 @@ def url_replace(context, **kwargs):
         return dict_.urlencode()
     
     
    -@register.simple_tag
    -def has_real_admin_users() -> bool:
    -    """True if any non-``system`` superuser exists. Used by the login page to
    -    only show the bootstrap hint (createsuperuser / ADMIN_USERNAME env vars)
    -    when the collection still has no real admin."""
    -    from django.contrib.auth.models import User
    -
    -    return User.objects.filter(is_superuser=True).exclude(username="system").exists()
    -
    -
     @register.simple_tag(takes_context=True)
     def admin_base_url(context) -> str:
         return get_admin_base_url(request=context.get("request"), config=context.get("CONFIG"))
    diff --git a/archivebox/templates/admin/login.html b/archivebox/templates/admin/login.html
    index 12257028..ab0f9bdd 100644
    --- a/archivebox/templates/admin/login.html
    +++ b/archivebox/templates/admin/login.html
    @@ -1,5 +1,5 @@
     {% extends "admin/base_site.html" %}
    -{% load i18n static core_tags %}
    +{% load i18n static %}
     
     {% block extrastyle %}{{ block.super }}
     {{ form.media }}
    @@ -18,7 +18,11 @@
     
     {% block content_title %}
       
    + {% if first_admin_setup %} + Create your first admin account to continue setup. + {% else %} Log in to add, edit, and remove links from your archive. + {% endif %}



    {% endblock %} @@ -42,6 +46,28 @@
    +{% if first_admin_setup %} +
    {% csrf_token %} + +
    + {{ form.username.errors }} + {{ form.username.label_tag }} {{ form.username }} +
    +
    + {{ form.password1.errors }} + {{ form.password1.label_tag }} {{ form.password1 }} + {% if form.password1.help_text %}
    {{ form.password1.help_text|safe }}
    {% endif %} +
    +
    + {{ form.password2.errors }} + {{ form.password2.label_tag }} {{ form.password2 }} + {% if form.password2.help_text %}
    {{ form.password2.help_text|safe }}
    {% endif %} +
    +
    + +
    +
    +{% else %} {% if user.is_authenticated %}

    {% blocktrans trimmed %} @@ -66,6 +92,7 @@

    +{% endif %}


    @@ -85,10 +112,6 @@ archivebox manage changepassword <username>
     
     
    - {% has_real_admin_users as real_admins_exist %} - {% if not real_admins_exist %} - (or set env vars ADMIN_USERNAME + ADMIN_PASSWORD) - {% endif %}
    diff --git a/archivebox/tests/test_server_security_browser.py b/archivebox/tests/test_server_security_browser.py index 68819135..5778e607 100644 --- a/archivebox/tests/test_server_security_browser.py +++ b/archivebox/tests/test_server_security_browser.py @@ -164,8 +164,11 @@ async function main() { const page = await browser.newPage(); await page.goto(config.loginUrl, {waitUntil: "networkidle2", timeout: 15000}); + const firstAdminForm = await page.$("#first-admin-form"); + if (!firstAdminForm) throw new Error("First admin setup form was not shown"); await page.type('input[name="username"]', config.username); - await page.type('input[name="password"]', config.password); + await page.type('input[name="password1"]', config.password); + await page.type('input[name="password2"]', config.password); await Promise.all([ page.waitForNavigation({waitUntil: "networkidle2", timeout: 15000}), page.click('button[type="submit"], input[type="submit"]'), @@ -202,6 +205,10 @@ async function main() { page.waitForNavigation({waitUntil: "networkidle2", timeout: 15000}), page.$eval('button[name="_continue"][form="machine_form"]', (button) => button.click()), ]); + await page.reload({waitUntil: "networkidle2", timeout: 15000}); + if (await page.$("#archivebox-setup-wizard")) { + throw new Error("Setup wizard remained visible after BASE_URL was saved"); + } console.log(JSON.stringify({finalUrl: page.url(), bodyText: await page.$eval("body", el => el.innerText.slice(0, 500))})); await browser.close(); @@ -664,8 +671,6 @@ def test_unconfigured_public_host_superuser_can_reach_setup_wizard(tmp_path: Pat env = cli_env( port=port, disable_extractors=True, - ADMIN_USERNAME="testadmin", - ADMIN_PASSWORD="testpassword", ALLOWED_HOSTS="*", BIND_ADDR=f"127.0.0.1:{port}", ) @@ -709,7 +714,7 @@ def test_unconfigured_public_host_superuser_can_reach_setup_wizard(tmp_path: Pat "hostname": public_hostname, "loginUrl": f"http://{public_host}/admin/login/?next=/admin/", "username": "testadmin", - "password": "testpassword", + "password": "ArchiveBox-test-9vK!", }, ), capture_output=True, diff --git a/docs/Docker.md b/docs/Docker.md index fb8758c6..e712b571 100644 --- a/docs/Docker.md +++ b/docs/Docker.md @@ -63,13 +63,14 @@ mkdir -p ~/archivebox/data && cd ~/archivebox curl -fsSL 'https://docker-compose.archivebox.io' > docker-compose.yml # (shortcut for getting https://raw.githubusercontent.com/ArchiveBox/ArchiveBox/dev/docker-compose.yml) -# pull and start the current image, then create an admin user for the Web UI +# pull and start the current image # (the server initializes a new collection automatically) docker compose pull docker compose up -d --wait -docker compose exec archivebox archivebox manage createsuperuser ``` +Open and follow the setup wizard to create the first admin and configure web access. Existing `BASE_URL` and security settings are used as-is, so configured servers skip the web-access wizard. + ArchiveBox installs and enables both ripgrep and [Sonic](https://github.com/valeriansaliou/sonic). Sonic is selected by default in the UI, while ripgrep remains available as the fallback. To select ripgrep explicitly: ```bash docker compose exec archivebox archivebox config --set SEARCH_BACKEND_ENGINE=ripgrep @@ -198,10 +199,11 @@ docker pull archivebox/archivebox:dev mkdir -p ~/archivebox/data && cd ~/archivebox/data docker run --rm -it -v "$PWD:/data" archivebox/archivebox:dev init -docker run --rm -it -v "$PWD:/data" archivebox/archivebox:dev manage createsuperuser docker run -d --name archivebox -v "$PWD:/data" -p 8000:8000 archivebox/archivebox:dev ``` +Then open and follow the setup wizard. + *(You can create a collection in any directory you want, `~/archivebox/data` is just used as an example here)* If you encounter permissions issues, make sure the mounted data directory is writable by its intended owner. Docker startup automatically uses the first non-root owner detected from the existing collection, or the default `archivebox` user when the data directory is root-owned. diff --git a/docs/Install.md b/docs/Install.md index 0f88a337..d4168e8d 100644 --- a/docs/Install.md +++ b/docs/Install.md @@ -76,7 +76,7 @@ If you're on macOS or Ubuntu, there is an optional auto-setup script provided. curl -fsSL 'https://get.archivebox.io' | bash # shortcut to run https://raw.githubusercontent.com/ArchiveBox/ArchiveBox/dev/bin/setup.sh ``` -The script uses Docker Compose when available, otherwise plain Docker when it can pull the released image. If Docker is unavailable, it shows the native `uv` install plan and pauses so you can cancel before continuing. It initializes the collection, installs ArchiveBox's runtime dependencies, and starts the server; create the first admin afterward with the command printed at the end. +The script uses Docker Compose when available, otherwise plain Docker when it can pull the released image. If Docker is unavailable, it shows the native `uv` install plan and pauses so you can cancel before continuing. It initializes the collection, installs ArchiveBox's runtime dependencies, and starts the server. Open the printed Admin UI URL and follow the setup wizard to create the first admin and configure web access. Run it as your normal user unless you want a system-owned deployment. When run as root, the script creates the `archivebox` service user and places the collection under that account's home directory; it prints the exact path and follow-up commands when finished.