Kategorie: Praxis

  • Website ist fertig

    Nachdem ich mich am Wochenende davon überzeugen konnte, daß auch mein privater Mastodon-Server tadellos läuft, gab es nur noch eines zu tun, nämlich die Suchfunktion meiner Website nochmal zu überarbeiten, sowie die Darstellung von Links so aufzubauen, daß man sie als Links einwandfrei erkennen kann (’n bißchen old-school ist hier nicht verkehrt, schließlich stellt sich irgendwann die Frage danach, was einzigartig überhaupt bedeutet, wenn es um das Interface einer Website wie dieser geht).

    Und was bleibt jetzt noch anderes zu tun, als sich einfach kurz zurückzulehnen und sich selbst zu sagen: gut gemacht, Ziel erfüllt.

    Eine Schaffenspause ist hier durchaus angebracht, denn jede freie Minute wurde seit einigen Monaten von Arbeiten an Website und Server aufgefressen.

    In diesem Sinne: hallo, willkommen zurück, und bis demnächst!

    Rio

  • How to retire a ghost ActivityPub account left behind by WordPress and move it to Mastodon

    The ActivityPub plugin for WordPress can turn your website, your WordPress user account, or both into actors in the Fediverse.

    If you later uninstall the plugin, those actors do not necessarily disappear from remote Mastodon servers. Other servers may continue to remember them, search for them, display them, or send requests to inbox URLs that no longer exist.

    This can leave you with what looks like a ghost account:

    @yourname@www.example.com
    

    while your actual Mastodon account is:

    @yourname@mastodon.example
    

    Simply uninstalling ActivityPub does not tell the rest of the Fediverse that the old actor has moved. Returning a generic 404 Not Found does not help either.

    The proper repair is:

    1. temporarily restore the old WordPress actor;
    2. add it as an alias of the real Mastodon account;
    3. send a signed ActivityPub Move;
    4. preserve the retired actor and its movedTo property;
    5. close its old inbox with 410 Gone;
    6. remove the full ActivityPub plugin without erasing the migration record.

    This tutorial walks through that entire procedure on a Plesk server using WP Toolkit and WP-CLI.

    Disclosure

    This tutorial was made with ChatGPT.

    ChatGPT and I successfully implemented and verified this migration on my own WordPress website and Mastodon server. I executed every command, inspected the responses, verified the database records, removed the ActivityPub plugin, and tested the final retired actors and inboxes.

    The tested environment was:

    Plesk Obsidian
    WordPress 7.0
    ActivityPub for WordPress 9.0.1
    PHP 8.2
    Mastodon 4.6.0
    Ubuntu Linux
    

    Plugin interfaces and internal paths may change in later versions. Read each command before running it and keep a complete website and database backup.


    What this procedure does

    The procedure creates a proper ActivityPub migration from an old WordPress actor to a real Mastodon actor.

    The old actor will publish:

    {
      "movedTo": "https://mastodon.example/users/alice"
    }
    

    Its outbox will contain a Move activity resembling:

    {
      "type": "Move",
      "object": "https://www.example.com/?author=1",
      "target": "https://mastodon.example/users/alice"
    }
    

    The new Mastodon actor must reciprocally list the old actor under:

    {
      "alsoKnownAs": [
        "https://www.example.com/?author=1"
      ]
    }
    

    This reciprocal relationship prevents one account from falsely claiming that it moved to an unrelated person.

    What it does not do

    This procedure does not:

    • copy old WordPress posts into Mastodon;
    • guarantee immediate deletion from every remote server;
    • force every Fediverse implementation to refresh its cache immediately;
    • forward old inbox traffic into Mastodon;
    • turn an HTTP redirect into an account migration.

    The old actor remains a small, machine-readable retirement notice.


    Why forwarding the old inbox is the wrong solution

    Suppose remote servers are still sending requests to:

    https://www.example.com/wp-json/activitypub/1.0/actors/0/inbox
    

    It may seem reasonable to proxy those requests to:

    https://mastodon.example/users/alice/inbox
    

    Do not do this.

    ActivityPub deliveries are signed for a particular host, request target and actor identity. Rewriting the destination does not transform an activity addressed to one actor into an activity addressed to another actor.

    A reverse proxy relocates an HTTP request. It does not transfer an ActivityPub identity.

    The correct mechanism is a Move activity.


    Before beginning

    You need:

    • SSH root access to the Plesk server;
    • access to the WordPress administration interface;
    • access to the destination Mastodon account;
    • Plesk WP Toolkit;
    • the ActivityPub plugin temporarily installed and active;
    • curl;
    • Python 3;
    • a current website and database backup.

    jq is not required. All JSON examples in this tutorial use Python.


    Part 1: define your installation values

    Begin by listing the WordPress installations known to Plesk:

    plesk ext wp-toolkit --list
    

    Example:

    ID  Installation Path  Website URL
    8   /httpdocs          https://www.example.com
    

    Set the values for your installation:

    WP_INSTANCE_ID='8'
    
    VHOST='/var/www/vhosts/example.com'
    DOCROOT="$VHOST/httpdocs"
    
    SITE_HOST='www.example.com'
    
    TARGET_HANDLE='alice@mastodon.example'
    TARGET_ACTOR='https://mastodon.example/users/alice'
    
    PHP_BIN='/opt/plesk/php/8.2/bin/php'
    

    Replace every example value with the real value for your server.

    Do not guess the destination actor URL. Mastodon actor URLs normally resemble:

    https://mastodon.example/users/alice
    

    They are not necessarily the same as the human profile URL:

    https://mastodon.example/@alice
    

    Part 2: restore the ActivityPub plugin temporarily

    If ActivityPub is still installed but inactive:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      plugin activate activitypub
    

    If it was deleted:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      plugin install activitypub --activate
    

    Check the installed version:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      plugin get activitypub \
      --fields=name,status,version \
      --format=table
    

    Example:

    Field    Value
    name     activitypub
    version  9.0.1
    status   active
    

    Do not remove the plugin again until the migration, export and retirement handler have all been verified.


    Part 3: identify every old WordPress actor

    A WordPress installation may expose more than one ActivityPub actor.

    This was the crucial detail in my case.

    I had:

    @mario@www.mariobreskic.de
    

    which resolved to:

    https://www.mariobreskic.de/?author=1
    

    and a separate site-wide blog actor:

    @mariobreskic.de@www.mariobreskic.de
    

    which resolved to:

    https://www.mariobreskic.de/?author=0
    

    They had separate inboxes:

    /wp-json/activitypub/1.0/actors/1/inbox
    /wp-json/activitypub/1.0/actors/0/inbox
    

    A request to /actors/0/inbox therefore did not belong to the same actor as /actors/1/inbox.

    3.1 Check the user actor with WebFinger

    Replace the handle:

    curl -fsS -G \
      -H 'Accept: application/jrd+json' \
      --data-urlencode \
      'resource=acct:alice@www.example.com' \
      'https://www.example.com/.well-known/webfinger' |
    python3 -m json.tool
    

    Look for:

    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://www.example.com/?author=1"
    }
    

    The href value is the canonical ActivityPub actor ID.

    It is not the inbox URL.

    3.2 Check for a site-wide blog actor

    The blog actor may have a handle based on the website domain:

    curl -fsS -G \
      -H 'Accept: application/jrd+json' \
      --data-urlencode \
      'resource=acct:example.com@www.example.com' \
      'https://www.example.com/.well-known/webfinger' |
    python3 -m json.tool
    

    A blog actor may resolve to:

    https://www.example.com/?author=0
    

    and may use the ActivityPub type:

    Group
    

    rather than:

    Person
    

    That is normal.

    3.3 If the blog actor cannot be found

    Open the WordPress administration interface and locate the ActivityPub profile settings.

    Enable the blog-wide or site-wide profile temporarily.

    Its identifier must match the old identifier used when the account was originally federated.

    Then save the WordPress permalink settings once:

    Settings
    → Permalinks
    → Save Changes
    

    Do not change the permalink structure. Saving it refreshes WordPress rewrite rules.

    Repeat the WebFinger request afterwards.


    Part 4: inspect each actor

    Inspect the user actor:

    curl -fsS \
      -H 'Accept: application/activity+json' \
      'https://www.example.com/?author=1' |
    python3 -c '
    import json
    import sys
    
    data = json.load(sys.stdin)
    
    for key in (
        "id",
        "type",
        "preferredUsername",
        "inbox",
        "outbox",
        "followers",
        "alsoKnownAs",
        "movedTo",
    ):
        print(f"{key}: {data.get(key)}")
    '
    

    Inspect the blog actor:

    curl -fsS \
      -H 'Accept: application/activity+json' \
      'https://www.example.com/?author=0' |
    python3 -c '
    import json
    import sys
    
    data = json.load(sys.stdin)
    
    for key in (
        "id",
        "type",
        "preferredUsername",
        "inbox",
        "outbox",
        "followers",
        "alsoKnownAs",
        "movedTo",
    ):
        print(f"{key}: {data.get(key)}")
    '
    

    Record the exact id value of every actor.

    For the remaining examples, assume:

    OLD_ACTOR_0='https://www.example.com/?author=0'
    OLD_HANDLE_0='example.com@www.example.com'
    
    OLD_ACTOR_1='https://www.example.com/?author=1'
    OLD_HANDLE_1='alice@www.example.com'
    

    Remove actor 0 or actor 1 from the procedure if your site only has one of them.


    Part 5: add the old actors as aliases in Mastodon

    Log in to the destination Mastodon account.

    Open:

    Preferences
    → Account
    → Moving from a different account
    → Create account alias
    

    Add the old handle:

    alice@www.example.com
    

    If the blog actor exists, add it separately:

    example.com@www.example.com
    

    Do not include a leading @ unless the interface specifically requests it.

    Adding an alias does not move anything by itself. It tells Mastodon that the destination account consents to being the target of a later migration.

    5.1 If Mastodon cannot find the old account

    Do not continue.

    Confirm that WebFinger works:

    curl -fsS -G \
      -H 'Accept: application/jrd+json' \
      --data-urlencode \
      'resource=acct:example.com@www.example.com' \
      'https://www.example.com/.well-known/webfinger' |
    python3 -m json.tool
    

    Also try searching inside Mastodon for:

    @example.com@www.example.com
    

    or for the direct actor URL:

    https://www.example.com/?author=0
    

    Once Mastodon can resolve the actor, retry the alias form.


    Part 6: verify the aliases on the destination actor

    The destination Mastodon actor must publish every old actor URL under alsoKnownAs.

    Run:

    curl -fsS \
      -H 'Accept: application/activity+json' \
      "$TARGET_ACTOR" |
    python3 -c '
    import json
    import sys
    
    data = json.load(sys.stdin)
    
    print("id:", data.get("id"))
    print("alsoKnownAs:")
    
    for value in data.get("alsoKnownAs", []):
        print("  " + value)
    '
    

    Required result:

    https://www.example.com/?author=0
    https://www.example.com/?author=1
    

    Do not run the Move command for an actor whose URL is absent.


    Part 7: check whether WordPress retained followers

    For actor 1:

    curl -fsS \
      -H 'Accept: application/activity+json' \
      'https://www.example.com/wp-json/activitypub/1.0/actors/1/followers' |
    python3 -c '
    import json
    import sys
    
    data = json.load(sys.stdin)
    print("type:", data.get("type"))
    print("totalItems:", data.get("totalItems"))
    '
    

    For actor 0:

    curl -fsS \
      -H 'Accept: application/activity+json' \
      'https://www.example.com/wp-json/activitypub/1.0/actors/0/followers' |
    python3 -c '
    import json
    import sys
    
    data = json.load(sys.stdin)
    print("type:", data.get("type"))
    print("totalItems:", data.get("totalItems"))
    '
    

    A result such as:

    totalItems: 0
    

    does not prevent the migration.

    It means WordPress has no locally stored follower inboxes to notify directly. The old actor can still publish movedTo, and remote servers can discover it when they refresh the actor.


    Part 8: run ActivityPub commands correctly through Plesk

    Plesk integrates WP-CLI through WP Toolkit.

    Test the ActivityPub Move command:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      activitypub move --help \
      --skip-plugins=false
    

    The final option is important:

    --skip-plugins=false
    

    Without it, Plesk may run WP-CLI without loading ordinary WordPress plugins. The result will be:

    Error: 'activitypub' is not a registered wp command.
    

    The plugin can be active in WordPress while still being absent from the WP-CLI runtime.


    Part 9: move the user actor

    Run:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      activitypub move \
      'https://www.example.com/?author=1' \
      "$TARGET_ACTOR" \
      --skip-plugins=false
    

    Expected result:

    Success: Move Scheduled.
    

    Do not repeat the command after it succeeds.


    Part 10: move the blog actor

    Run:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      activitypub move \
      'https://www.example.com/?author=0' \
      "$TARGET_ACTOR" \
      --skip-plugins=false
    

    Expected:

    Success: Move Scheduled.
    

    Again, do not repeat it after success.


    Troubleshooting: “Invalid target” or “Ungültiges Ziel”

    This means WordPress fetched the destination actor but did not find the old actor URL under alsoKnownAs.

    First verify the destination actor again:

    curl -fsS \
      -H 'Accept: application/activity+json' \
      "$TARGET_ACTOR" |
    python3 -m json.tool
    

    If the alias is present publicly, WordPress may have cached an older copy.

    Generate the plugin’s cache key:

    CACHE_KEY=$(
      python3 - "$TARGET_ACTOR" <<'PY'
    import hashlib
    import sys
    
    url = sys.argv[1]
    print("activitypub_http_" + hashlib.md5(url.encode()).hexdigest())
    PY
    )
    

    Display it:

    echo "$CACHE_KEY"
    

    Delete the transient:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      transient delete "$CACHE_KEY"
    

    Then inspect what WordPress fetches without cache:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- eval '
    $url = "https://mastodon.example/users/alice";
    
    $data = \Activitypub\Http::get_remote_object($url, false);
    
    if (is_wp_error($data)) {
        WP_CLI::error(
            $data->get_error_code() . ": " .
            $data->get_error_message()
        );
    }
    
    echo "id: " . ($data["id"] ?? "MISSING") . PHP_EOL;
    echo "alsoKnownAs:" . PHP_EOL;
    
    foreach (($data["alsoKnownAs"] ?? array()) as $alias) {
        echo "  " . $alias . PHP_EOL;
    }
    ' --skip-plugins=false
    

    Replace the target URL inside the PHP code.

    After the correct alias appears, run the Move command once more.

    Important warning for ActivityPub 9.0.1

    Do not experiment with incorrect destination URLs.

    In version 9.0.1, the plugin writes the movedTo setting before it completes target validation. Verify the destination and its aliases before running the command.


    Part 11: verify the public actors

    Check both:

    for NUMBER in 0 1; do
      echo "=== actor $NUMBER ==="
    
      curl -fsS \
        -H 'Accept: application/activity+json' \
        "https://www.example.com/?author=$NUMBER" |
      python3 -c '
    import json
    import sys
    
    data = json.load(sys.stdin)
    
    print("id:", data.get("id"))
    print("type:", data.get("type"))
    print("movedTo:", data.get("movedTo"))
    '
    
      echo
    done
    

    Expected:

    === actor 0 ===
    id: https://www.example.com/?author=0
    type: Group
    movedTo: https://mastodon.example/users/alice
    
    === actor 1 ===
    id: https://www.example.com/?author=1
    type: Person
    movedTo: https://mastodon.example/users/alice
    

    Part 12: verify the Move activities in the database

    The public outbox REST page may be paginated or may not show the newest Move where you expect it.

    Query the WordPress outbox records directly:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- eval '
    $posts = get_posts(
        array(
            "post_type"      => "ap_outbox",
            "post_status"    => "any",
            "posts_per_page" => 100,
            "orderby"        => "ID",
            "order"          => "DESC",
        )
    );
    
    foreach ($posts as $post) {
        $data = json_decode($post->post_content, true);
    
        if (($data["type"] ?? "") !== "Move") {
            continue;
        }
    
        echo "ID: " . $post->ID . PHP_EOL;
        echo "type: " . ($data["type"] ?? "MISSING") . PHP_EOL;
        echo "object: " . ($data["object"] ?? "MISSING") . PHP_EOL;
        echo "target: " . ($data["target"] ?? "MISSING") . PHP_EOL;
        echo PHP_EOL;
    }
    ' --skip-plugins=false
    

    You need one Move for every retired actor:

    object: https://www.example.com/?author=0
    target: https://mastodon.example/users/alice
    

    and:

    object: https://www.example.com/?author=1
    target: https://mastodon.example/users/alice
    

    Part 13: archive the final actor state

    Do not uninstall ActivityPub yet.

    Create an archive outside the public document root:

    ARCHIVE="$VHOST/private/activitypub-retired"
    
    SITE_USER=$(stat -c '%U' "$DOCROOT/wp-config.php")
    SITE_GROUP=$(stat -c '%G' "$DOCROOT/wp-config.php")
    
    install -d \
      -o "$SITE_USER" \
      -g "$SITE_GROUP" \
      -m 0750 \
      "$ARCHIVE"
    

    13.1 Save the actor documents

    curl -fsS \
      -H 'Accept: application/activity+json' \
      'https://www.example.com/?author=0' \
      -o "$ARCHIVE/actor-0.json"
    
    curl -fsS \
      -H 'Accept: application/activity+json' \
      'https://www.example.com/?author=1' \
      -o "$ARCHIVE/actor-1.json"
    

    13.2 Save the WebFinger documents

    curl -fsS -G \
      -H 'Accept: application/jrd+json' \
      --data-urlencode \
      'resource=acct:example.com@www.example.com' \
      'https://www.example.com/.well-known/webfinger' \
      -o "$ARCHIVE/webfinger-0.json"
    
    curl -fsS -G \
      -H 'Accept: application/jrd+json' \
      --data-urlencode \
      'resource=acct:alice@www.example.com' \
      'https://www.example.com/.well-known/webfinger' \
      -o "$ARCHIVE/webfinger-1.json"
    

    13.3 Export the Move activities

    Edit the two actor URLs inside this command:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- eval '
    $actors = array(
        "0" => "https://www.example.com/?author=0",
        "1" => "https://www.example.com/?author=1",
    );
    
    $directory =
        "/var/www/vhosts/example.com/private/activitypub-retired";
    
    $posts = get_posts(
        array(
            "post_type"      => "ap_outbox",
            "post_status"    => "any",
            "posts_per_page" => 100,
            "orderby"        => "ID",
            "order"          => "DESC",
        )
    );
    
    $found = array();
    
    foreach ($posts as $post) {
        $data = json_decode($post->post_content, true);
    
        if (
            !is_array($data) ||
            ($data["type"] ?? "") !== "Move"
        ) {
            continue;
        }
    
        foreach ($actors as $number => $actor_url) {
            if (
                !isset($found[$number]) &&
                ($data["object"] ?? "") === $actor_url
            ) {
                $filename =
                    $directory . "/move-" . $number . ".json";
    
                $json = wp_json_encode(
                    $data,
                    JSON_PRETTY_PRINT |
                    JSON_UNESCAPED_SLASHES |
                    JSON_UNESCAPED_UNICODE
                );
    
                if (
                    false === file_put_contents(
                        $filename,
                        $json . PHP_EOL
                    )
                ) {
                    WP_CLI::error(
                        "Could not write " . $filename
                    );
                }
    
                $found[$number] = $post->ID;
            }
        }
    }
    
    foreach ($actors as $number => $actor_url) {
        if (!isset($found[$number])) {
            WP_CLI::error(
                "No Move activity found for actor " . $number
            );
        }
    }
    
    foreach ($found as $number => $post_id) {
        echo "actor " . $number .
            ": outbox post " . $post_id .
            PHP_EOL;
    }
    ' --skip-plugins=false
    

    Correct the archive directory in the PHP code before running it.

    13.4 Set permissions

    chown "$SITE_USER:$SITE_GROUP" "$ARCHIVE"/*.json
    chmod 0640 "$ARCHIVE"/*.json
    
    ls -la "$ARCHIVE"
    

    Expected files:

    actor-0.json
    actor-1.json
    move-0.json
    move-1.json
    webfinger-0.json
    webfinger-1.json
    

    Part 14: validate the archive

    python3 - <<'PY'
    import json
    from pathlib import Path
    
    base = Path(
        "/var/www/vhosts/example.com/private/activitypub-retired"
    )
    
    target = "https://mastodon.example/users/alice"
    
    for number in ("0", "1"):
        with (
            base / f"actor-{number}.json"
        ).open(encoding="utf-8") as file:
            actor = json.load(file)
    
        with (
            base / f"webfinger-{number}.json"
        ).open(encoding="utf-8") as file:
            webfinger = json.load(file)
    
        with (
            base / f"move-{number}.json"
        ).open(encoding="utf-8") as file:
            move = json.load(file)
    
        self_links = [
            link.get("href")
            for link in webfinger.get("links", [])
            if link.get("rel") == "self"
        ]
    
        print(f"=== actor {number} ===")
        print("actor id:", actor.get("id"))
        print("actor type:", actor.get("type"))
        print("movedTo:", actor.get("movedTo"))
        print("WebFinger self:", self_links)
        print("Move type:", move.get("type"))
        print("Move object:", move.get("object"))
        print("Move target:", move.get("target"))
    
        assert actor.get("movedTo") == target
        assert move.get("type") == "Move"
        assert move.get("object") == actor.get("id")
        assert move.get("target") == target
        assert actor.get("id") in self_links
    
        print("VALID")
        print()
    
    print("All retirement records are valid.")
    PY
    

    Edit the archive path and target actor before running it.

    Do not continue unless every actor prints:

    VALID
    

    Part 15: install a permanent retirement handler

    The full ActivityPub plugin is no longer needed after the migration, but the old identities should remain resolvable.

    We will install a small must-use WordPress plugin.

    It will:

    • serve the archived WebFinger responses;
    • serve the archived actor documents;
    • serve the archived Move objects;
    • return 410 Gone from the old inboxes;
    • leave ordinary HTML author pages untouched.

    Create the file:

    cat > /tmp/activitypub-retirement.php <<'PHP'
    <?php
    /**
     * Plugin Name: Retired ActivityPub Identities
     * Description: Preserves moved ActivityPub actors after removing the ActivityPub plugin.
     * Version: 1.0.0
     */
    
    defined( 'ABSPATH' ) || exit;
    
    /*
     * CHANGE THESE VALUES.
     */
    const MB_AP_RETIREMENT_ARCHIVE =
    	'/var/www/vhosts/example.com/private/activitypub-retired';
    
    const MB_AP_RETIREMENT_ORIGIN =
    	'https://www.example.com';
    
    const MB_AP_RETIREMENT_TARGET =
    	'https://mastodon.example/users/alice';
    
    
    function mb_ap_retirement_send_file(
    	string $filename,
    	string $content_type
    ): void {
    	$path = MB_AP_RETIREMENT_ARCHIVE . '/' . $filename;
    
    	if ( ! is_readable( $path ) ) {
    		status_header( 500 );
    		header( 'Content-Type: application/json; charset=utf-8' );
    		header( 'Cache-Control: no-store' );
    
    		echo wp_json_encode(
    			array(
    				'error'   => 'Retirement archive unavailable',
    				'missing' => $filename,
    			)
    		);
    
    		exit;
    	}
    
    	status_header( 200 );
    
    	header(
    		'Content-Type: ' .
    		$content_type .
    		'; charset=utf-8'
    	);
    
    	header( 'Cache-Control: public, max-age=3600' );
    	header( 'X-Content-Type-Options: nosniff' );
    	header( 'Vary: Accept' );
    	header( 'Content-Length: ' . filesize( $path ) );
    
    	if (
    		'HEAD' !== strtoupper(
    			$_SERVER['REQUEST_METHOD'] ?? 'GET'
    		)
    	) {
    		readfile( $path );
    	}
    
    	exit;
    }
    
    
    function mb_ap_retirement_send_gone(): void {
    	$body = wp_json_encode(
    		array(
    			'error'   => 'Gone',
    			'message' => 'This ActivityPub actor has moved.',
    			'movedTo' => MB_AP_RETIREMENT_TARGET,
    		),
    		JSON_UNESCAPED_SLASHES
    	);
    
    	status_header( 410 );
    
    	header(
    		'Content-Type: application/activity+json; charset=utf-8'
    	);
    
    	header( 'Cache-Control: no-store' );
    	header( 'X-Content-Type-Options: nosniff' );
    
    	if (
    		'HEAD' !== strtoupper(
    			$_SERVER['REQUEST_METHOD'] ?? 'GET'
    		)
    	) {
    		echo $body;
    	}
    
    	exit;
    }
    
    
    function mb_ap_retirement_accepts_activitypub(): bool {
    	$accept = strtolower(
    		$_SERVER['HTTP_ACCEPT'] ?? ''
    	);
    
    	return str_contains(
    		$accept,
    		'application/activity+json'
    	) || str_contains(
    		$accept,
    		'application/ld+json'
    	);
    }
    
    
    add_action(
    	'parse_request',
    	static function (): void {
    		$request_uri =
    			$_SERVER['REQUEST_URI'] ?? '/';
    
    		$path = parse_url(
    			$request_uri,
    			PHP_URL_PATH
    		);
    
    		$method = strtoupper(
    			$_SERVER['REQUEST_METHOD'] ?? 'GET'
    		);
    
    		/*
    		 * CHANGE THE TWO acct: VALUES.
    		 */
    		if (
    			'/.well-known/webfinger' === rtrim(
    				(string) $path,
    				'/'
    			)
    			&& in_array(
    				$method,
    				array( 'GET', 'HEAD' ),
    				true
    			)
    		) {
    			$resource = isset( $_GET['resource'] )
    				? trim(
    					wp_unslash(
    						$_GET['resource']
    					)
    				)
    				: '';
    
    			$webfinger_files = array(
    				'acct:example.com@www.example.com'
    					=> 'webfinger-0.json',
    
    				'acct:alice@www.example.com'
    					=> 'webfinger-1.json',
    			);
    
    			if (
    				isset(
    					$webfinger_files[ $resource ]
    				)
    			) {
    				mb_ap_retirement_send_file(
    					$webfinger_files[ $resource ],
    					'application/jrd+json'
    				);
    			}
    
    			return;
    		}
    
    		/*
    		 * Serve the retired actors only when an
    		 * ActivityPub representation is requested.
    		 */
    		if (
    			'/' === $path
    			&& in_array(
    				$method,
    				array( 'GET', 'HEAD' ),
    				true
    			)
    			&& mb_ap_retirement_accepts_activitypub()
    		) {
    			$author = isset( $_GET['author'] )
    				? (string) wp_unslash(
    					$_GET['author']
    				)
    				: '';
    
    			$actor_files = array(
    				'0' => 'actor-0.json',
    				'1' => 'actor-1.json',
    			);
    
    			if ( isset( $actor_files[ $author ] ) ) {
    				mb_ap_retirement_send_file(
    					$actor_files[ $author ],
    					'application/activity+json'
    				);
    			}
    		}
    
    		/*
    		 * Preserve dereferenceable Move activity IDs.
    		 */
    		if (
    			in_array(
    				$method,
    				array( 'GET', 'HEAD' ),
    				true
    			)
    			&& mb_ap_retirement_accepts_activitypub()
    		) {
    			$current_url =
    				MB_AP_RETIREMENT_ORIGIN .
    				$request_uri;
    
    			foreach (
    				array( '0', '1' ) as $number
    			) {
    				$filename =
    					'move-' .
    					$number .
    					'.json';
    
    				$file =
    					MB_AP_RETIREMENT_ARCHIVE .
    					'/' .
    					$filename;
    
    				if ( ! is_readable( $file ) ) {
    					continue;
    				}
    
    				$move = json_decode(
    					file_get_contents( $file ),
    					true
    				);
    
    				if (
    					is_array( $move )
    					&& isset( $move['id'] )
    					&& $move['id'] === $current_url
    				) {
    					mb_ap_retirement_send_file(
    						$filename,
    						'application/activity+json'
    					);
    				}
    			}
    		}
    
    		/*
    		 * Retire old inboxes and collections.
    		 */
    		$normalised_path =
    			rtrim( (string) $path, '/' );
    
    		$is_retired_endpoint =
    			'/wp-json/activitypub/1.0/inbox'
    				=== $normalised_path
    			|| preg_match(
    				'#^/wp-json/activitypub/1\.0/' .
    				'actors/[01]/' .
    				'(?:inbox|outbox(?:/stream)?|' .
    				'followers|following|liked|' .
    				'collections(?:/.*)?)$#',
    				$normalised_path
    			);
    
    		if ( $is_retired_endpoint ) {
    			mb_ap_retirement_send_gone();
    		}
    	},
    	-1000
    );
    PHP
    

    Edit these values before installing:

    MB_AP_RETIREMENT_ARCHIVE
    MB_AP_RETIREMENT_ORIGIN
    MB_AP_RETIREMENT_TARGET
    the two WebFinger acct: identifiers
    

    If you only have one retired actor, remove the unused map entries and files.

    15.1 Check the syntax

    "$PHP_BIN" -l /tmp/activitypub-retirement.php
    

    Required:

    No syntax errors detected
    

    15.2 Install it as a must-use plugin

    MUPLUGINS="$DOCROOT/wp-content/mu-plugins"
    
    install -d \
      -o "$SITE_USER" \
      -g "$SITE_GROUP" \
      -m 0755 \
      "$MUPLUGINS"
    
    install \
      -o "$SITE_USER" \
      -g "$SITE_GROUP" \
      -m 0644 \
      /tmp/activitypub-retirement.php \
      "$MUPLUGINS/activitypub-retirement.php"
    

    Confirm:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      plugin list \
      --status=must-use \
      --fields=name,status,version \
      --format=table
    

    Expected:

    activitypub-retirement  must-use  1.0.0
    

    Part 16: test the retirement handler before removing ActivityPub

    16.1 Test WebFinger

    for RESOURCE in \
      'acct:example.com@www.example.com' \
      'acct:alice@www.example.com'
    do
      echo "=== $RESOURCE ==="
    
      curl -fsS -G \
        -H 'Accept: application/jrd+json' \
        --data-urlencode "resource=$RESOURCE" \
        'https://www.example.com/.well-known/webfinger' |
      python3 -c '
    import json
    import sys
    
    data = json.load(sys.stdin)
    
    print("subject:", data.get("subject"))
    
    for link in data.get("links", []):
        if link.get("rel") == "self":
            print("self:", link.get("href"))
    '
    
      echo
    done
    

    16.2 Test both actors

    for NUMBER in 0 1; do
      echo "=== actor $NUMBER ==="
    
      curl -fsS \
        -H 'Accept: application/activity+json' \
        "https://www.example.com/?author=$NUMBER" |
      python3 -c '
    import json
    import sys
    
    data = json.load(sys.stdin)
    
    print("id:", data.get("id"))
    print("type:", data.get("type"))
    print("movedTo:", data.get("movedTo"))
    '
    
      echo
    done
    

    16.3 Confirm normal HTML remains available

    for NUMBER in 0 1; do
      curl -sS \
        -o /dev/null \
        -H 'Accept: text/html' \
        -w "author=$NUMBER: HTTP %{http_code}, %{content_type}\n" \
        "https://www.example.com/?author=$NUMBER"
    done
    

    A 200 or normal WordPress 301 redirect is acceptable.

    16.4 Test the retired inboxes

    for NUMBER in 0 1; do
      curl -sS \
        -o /dev/null \
        -X POST \
        -H 'Content-Type: application/activity+json' \
        -d '{}' \
        -w "actor $NUMBER inbox: HTTP %{http_code}\n" \
        "https://www.example.com/wp-json/activitypub/1.0/actors/$NUMBER/inbox"
    done
    

    Required:

    HTTP 410
    

    Test the shared inbox:

    curl -sS \
      -o /dev/null \
      -X POST \
      -H 'Content-Type: application/activity+json' \
      -d '{}' \
      -w "shared inbox: HTTP %{http_code}\n" \
      'https://www.example.com/wp-json/activitypub/1.0/inbox'
    

    Required:

    HTTP 410
    

    Part 17: deactivate ActivityPub

    Do not delete it immediately.

    Deactivate it first:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      plugin deactivate activitypub \
      --skip-plugins=false
    

    Confirm:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      plugin get activitypub \
      --fields=name,status,version \
      --format=table
    

    Status must be:

    inactive
    

    Repeat all tests from Part 16.

    The WebFinger documents, actor documents and 410 Gone inboxes must continue working with the main ActivityPub plugin inactive.


    Part 18: delete the ActivityPub plugin files

    Only after every test succeeds:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      plugin delete activitypub
    

    The retirement MU-plugin is separate and will remain installed.

    Confirm:

    plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
      plugin list \
      --fields=name,status,version \
      --format=table |
    grep -E '^(name|activitypub)'
    

    Only the table header should remain.


    Part 19: final verification

    Use a saved response body rather than piping directly into Python. This produces clearer diagnostics if a proxy cache briefly returns an empty response.

    for NUMBER in 0 1; do
      BODY="/tmp/retired-actor-$NUMBER.json"
    
      echo "=== actor $NUMBER ==="
    
      curl --fail-with-body -sS \
        -H 'Accept: application/activity+json' \
        -H 'Cache-Control: no-cache' \
        "https://www.example.com/?author=$NUMBER" \
        -o "$BODY"
    
      python3 -c '
    import json
    import sys
    
    with open(sys.argv[1], encoding="utf-8") as file:
        data = json.load(file)
    
    print("id:", data.get("id"))
    print("type:", data.get("type"))
    print("movedTo:", data.get("movedTo"))
    ' "$BODY"
    
      curl -sS \
        -o /dev/null \
        -X POST \
        -H 'Content-Type: application/activity+json' \
        -d '{}' \
        -w "inbox: HTTP %{http_code}\n" \
        "https://www.example.com/wp-json/activitypub/1.0/actors/$NUMBER/inbox"
    
      echo
    done
    

    Final expected state:

    actor 0:
    type: Group
    movedTo: real Mastodon actor
    inbox: HTTP 410
    
    actor 1:
    type: Person
    movedTo: real Mastodon actor
    inbox: HTTP 410
    

    Check both WebFinger identities once more:

    for RESOURCE in \
      'acct:example.com@www.example.com' \
      'acct:alice@www.example.com'
    do
      curl --fail-with-body -sS -G \
        -H 'Accept: application/jrd+json' \
        -H 'Cache-Control: no-cache' \
        --data-urlencode "resource=$RESOURCE" \
        'https://www.example.com/.well-known/webfinger' |
      python3 -c '
    import json
    import sys
    
    data = json.load(sys.stdin)
    
    print("subject:", data.get("subject"))
    
    for link in data.get("links", []):
        if link.get("rel") == "self":
            print("self:", link.get("href"))
    '
    done
    

    Common errors

    /usr/bin/env: php: No such file or directory

    Plesk may not expose a generic php binary in the shell path.

    Use the Plesk WP Toolkit wrapper:

    plesk ext wp-toolkit --wp-cli -instance-id 8 -- ...
    

    or invoke the configured Plesk PHP binary directly:

    /opt/plesk/php/8.2/bin/php
    

    Do not create a global PHP symlink merely to satisfy WP-CLI.

    'activitypub' is not a registered wp command

    Add:

    --skip-plugins=false
    

    to the WP Toolkit command.

    Mastodon cannot find the old account

    The actor is not currently discoverable through WebFinger.

    Temporarily re-enable the relevant ActivityPub profile in WordPress, save the permalink settings and verify:

    /.well-known/webfinger?resource=acct:username@domain
    

    before retrying the Mastodon alias.

    The follower count is zero

    This is not a migration failure.

    It means WordPress has no stored followers to notify directly. The actor can still publish movedTo, and remote servers can discover it later.

    The REST outbox does not display the Move

    Query the ap_outbox records directly with WP-CLI. Pagination and filtering can make the public outbox misleading.

    Python reports JSONDecodeError

    Save the HTTP body and headers first:

    curl -sS \
      -D /tmp/headers.txt \
      -o /tmp/body.json \
      -H 'Accept: application/activity+json' \
      'https://www.example.com/?author=0'
    

    Inspect:

    cat /tmp/headers.txt
    wc -c /tmp/body.json
    python3 -m json.tool /tmp/body.json
    

    A transient empty response during cache turnover does not necessarily mean the retirement handler is broken.

    Remote servers continue posting to the old inbox

    They may retain stale actor information for some time.

    The correct final response is:

    410 Gone
    

    Do not proxy those requests into Mastodon.


    What must remain permanently

    Keep these files:

    wp-content/mu-plugins/activitypub-retirement.php
    

    and:

    /private/activitypub-retired/
    

    Back them up with the rest of the website.

    Also retain the old account aliases on the destination Mastodon account. They are part of the reciprocal evidence that validates the Move.

    The final architecture is deliberately small:

    Old WordPress actor
        ↓
    movedTo
        ↓
    Real Mastodon actor
    

    The old actor remains readable, but inert.

    Its inbox is closed.

    Its identity is not forwarded, impersonated or silently discarded.

    The Fediverse receives an explicit statement: this actor existed, and it moved here.

    The protocol requirements were checked against Mastodon’s official documentation for Move, alsoKnownAs, account aliases and WebFinger. The WordPress commands and migration behavior were checked against ActivityPub for WordPress 9.0.1 source code, including its external-move implementation, cache key and CLI registration. The Plesk command form and plugin deactivation/deletion steps were checked against the official Plesk and WP-CLI documentation.

  • Update 7: Change Notes 30. März 2026

    Update 7: Change Notes 30. März 2026

    Layout

    Ich wage es, eine Website oben ohne Navigationsleiste zu haben. Stattdessen befindet sich die Navigation in der Sidebar. Das hat vor allem praktische Gründe: immer gnadenlos die Navigationsleiste überall und auf jeder Seite zu sehen, sogar auf jeder Website zu sehen, zeigt mir eine kreative Sackgasse. Warum also nicht den Content nach oben schieben? Ist ja meine Website.

    Typografie

    Aus ähnlichen Gründen wie beim Header habe ich mich mich gegen Webfonts, und für einen System Font Stack entschieden. Mit WordPress noch viel einfacher umzusetzen, als ich dachte.

    Bücherregal

    Neues Lesematerial aufbereitet. Besonderer Dank hier an Eva Czajkowski von Rémy & moi und Kyle T. Webster für die Hinweise auf diese Bücher.

    Socials

    Out of office und After hours 😉

  • BlackInk Brush: DEBUG Vertical RGB Stripes

    BlackInk Brush: DEBUG Vertical RGB Stripes

    Heute nochmal zurück zu einer alternativen Version eines meiner CRT-Pinsel gegangen und da lag bei mir glatt Armin Hofmanns „Methodik der Form- und Bildgestaltung“ auf dem Tisch, dessen Cover mich fasziniert. Was läge da näher, als eine kleine Studie zu machen, um einen Pinsel auszuprobieren?

    Der Code:

    cfg {
    name = "DEBUG Vertical RGB Stripes";
    renderingTime = 60;
    }

    float4 main(idatas i) {
    // Normalize stroke space UVs
    box2 b = box2FromCenterAxe(i.strokeStartPos, length(i.strokePos - i.strokeStartPos), normalizeSafe(i.strokePos - i.strokeStartPos));
    float2 uv = 4 * b.toCenter(i.pos) / b.size;

    // Scale up horizontal frequency
    float stripe = floor(uv.x * 60); // visible stripes

    // Simple stripe pattern: R, G, B
    float3 col = float3(0.0, 0.0, 0.0);
    if (mod(stripe, 3) == 0) {
    col.r = 1.0;
    } else if (mod(stripe, 3) == 1) {
    col.g = 1.0;
    } else {
    col.b = 1.0;
    }

    return float4(col, 1.0);
    }
  • BlackInk Brush: CRT Melt (Procedural)

    BlackInk Brush: CRT Melt (Procedural)

    Zwischen den Seiten von Neuromancer, und Zen & The Art of the Macintosh, habe ich wieder ChatGPT ausgepackt, und mit Hilfe folgender Dokumente BSL Doc und BSL Samples, diesen Pinsel rekonstruiert:1

    cfg{
    name = "CRT Melt (Procedural)";
    }

    globals{

    uiTab "CRT";

    float rgbOffset = 0.03;
    {
    uiTab = "CRT";
    uiName = "RGB Separation";
    uiMin = 0;
    uiMax = 0.2;
    }

    float meltStrength = 0.5;
    {
    uiTab = "CRT";
    uiName = "Vertical Melt";
    uiMin = 0;
    uiMax = 2;
    }

    float wobbleStrength = 0.3;
    {
    uiTab = "CRT";
    uiName = "Horizontal Wobble";
    uiMin = 0;
    uiMax = 2;
    }
    }

    float4 main( idatas i )
    {
    // ----- stroke space -----
    box2 b = box2FromCenterAxe(
    i.strokeStartPos,
    length(i.strokePos - i.strokeStartPos),
    normalizeSafe(i.strokePos - i.strokeStartPos)
    );

    float2 uv = b.toCenter(i.pos) / b.size;

    // ----- CRT distortions -----
    float melt = uv.y + sin( uv.x * 10 + i.time * 2 ) * meltStrength;
    float wobble = sin( uv.y * 40 + i.time * 4 ) * wobbleStrength;

    // ----- RGB sampling positions -----
    float ur = uv.x + wobble + rgbOffset;
    float ug = uv.x + wobble;
    float ub = uv.x + wobble - rgbOffset;

    // ----- stripe signal -----
    float sr = step( 0, sin( ur * 60 ) );
    float sg = step( 0, sin( ug * 60 ) );
    float sb = step( 0, sin( ub * 60 ) );

    float3 col = float3( sr, sg, sb );

    return float4( col, 1.0 );
    }

    Das dabei entstandene ChatGPT‑Projekt kann man bis auf weiteres hier abrufen und selbst verwenden, um eigene Pinsel für BlackInk zu entwickeln.

    1. Die Dokumente habe ich auf https://steamcommunity.com/app/233680/discussions/0/4031351002388283519/ gefunden ↩︎
  • Fix für titellose Posts in WordPress, für SEO, GEO, und Social Media

    Weil ich mehrere Microblogging-Kategorien habe, die keine Titel brauchen, hier ein Fix für das Erstellen eines Pseudotitels für SEO, GEO, und Social-Media-Links:

    // Shared function for title-less posts in certain categories
    function mb_title_for_titleless_notes( $title ) {

    if ( ! is_singular( 'post' ) ) {
    return $title;
    }

    global $post;

    // Only act if the post has no title
    if ( trim( $post->post_title ) !== '' ) {
    return $title;
    }

    // Get categories
    $categories = get_the_category( $post->ID );
    if ( empty( $categories ) ) {
    return $title;
    }

    // Allowed categories
    $allowed = [ 'macht', 'notiert', 'liest', 'hoert', 'schaut', 'spielt' ];
    $matched_category = null;

    foreach ( $categories as $cat ) {
    if ( in_array( $cat->slug, $allowed, true ) ) {
    $matched_category = $cat;
    break;
    }
    }

    if ( ! $matched_category ) {
    return $title;
    }

    // Build replacement title
    $date = get_the_date( 'j. F Y, G:i', $post ); // German
    $category_name = $matched_category->name;

    // Example: "Notiert: 9. Januar 2026, 9:46 Uhr - Mario Breskic"
    return sprintf(
    '%s: %s Uhr - Mario Breskic',
    $category_name,
    $date
    );
    }

    // HTML <title>
    add_filter( 'wpseo_title', 'mb_title_for_titleless_notes', 20 );

    // Open Graph <meta property="og:title">
    add_filter( 'wpseo_opengraph_title', 'mb_title_for_titleless_notes', 20 );

    // Twitter <meta name="twitter:title">
    add_filter( 'wpseo_twitter_title', 'mb_title_for_titleless_notes', 20 );
    // Built with ChatGPT
  • Update 6: Change Notes 18.10.2025

    Update 6: Change Notes 18.10.2025

    Layout verbessert

    • Sidebar und Paginierung eingeführt
    • Paginierung ist jetzt menschen­lesbar, und damit auch maschinen­lesbar
    • Paginierungs-Bug beseitigt
    • Vereinheitlichte Darstellung

    Links auf der Website

    • Sidebar verlinkt jetzt zu allen Seiten meiner Website
    • Sidebar beinhaltet jetzt eine Webroll (ersetzt damit die Blogroll)
    • Relationale Links nach XFN für Webroll und Social Links
    • Sidebar beinhaltet jetzt Links zu meinen Socials

    Artikel

    Shortform‑Artikel sind jetzt einfach ein normaler Bestand­teil des Feeds

  • Persisting Context: Kontext erhalten mit Index-Posts

    Ich beschäftige mich seit einigen Wochen mit einem der größten Probleme von Social Media: dass selbst Threads ihren Kontext über die Zeit verlieren.

    Wenn ich mehrere Posts verfasse, entstehen diese meist aus einer bestimmten Stimmung oder Absicht heraus. Wer in dem Moment live dabei ist, kann diesen Zusammenhang noch „hören“, fast wie den Tonfall meiner Stimme. Doch Social Media funktioniert überwiegend asynchron: ich schreibe jetzt, andere lesen irgendwann.

    Das Problem: Kontextverlust in Social Media

    Das Problem: Algorithmen zeigen Posts nicht in der ursprünglichen Reihenfolge, sondern nach vermuteter Aufmerksamkeit. So kann es passieren, dass jemand Monate später nur einen einzelnen Post von mir sieht – etwa über den Nutzen von Dokumentenscannern für Grafikdesigner mit einem Faible für Texturen – ohne zu wissen, warum ich ihn geschrieben habe oder ob das Thema für mich noch aktuell ist. Der Kontext ist verloren. Und auch ich gehe in den seltensten Fällen einfach mal irgendwessen Timeline durchsuchen, um zu rekonstruieren, um was es ging und ob es noch mehr gibt.

    Social Media wird dadurch zu einer Aneinanderreihung von Einzelpostings, die so chaotisch wirken können wie die Launen eines Glücksspielautomaten, sogar dann wenn sie von nur einem einzelnen Account stammen.

    “By treating a pinned post as a living index, Mario turns the most rigid feature of social media into its most flexible one — a single anchor that can grow, branch, and even hand off gracefully to the next index when it’s time to move on.”

    ChatGPT

    Die Lösung: Kontext erhalten mit Index-Posts

    Meine Lösung? Persisting Context – also Kontext erhalten. Dafür nutze ich einen gepinnten Beitrag als Index, an den ich thematische Threads anhänge. Jeder neue Thread verknüpft sich mit diesem Index, kann aber wiederum eigene Unterthemen entfalten. Auch experimentiere ich mich Randnotizen und Anmerkungen, im Moment mit den Hashtags #aside und #notes, falls ich zu einem bestimmten Post etwas anmerken möchte.

    Es entsteht eine Art Fächer: alle Threads gehen von einem gemeinsamen Index-Post aus, stellen aber ihre jedoch ihre eigenen Inhalte dar. So bleiben Themen klar voneinander getrennt, aber gleichzeitig im größeren Zusammenhang erkennbar.

    Zwei willkommene Nebeneffekte hat diese Methode auch: erstens bleibt durch den Erhalt des Kontextes auch die Sichtbarkeit der Posts erhalten, nichts geht mehr irgendwo verloren (außer man will es), und zweitens steigt die Anzahl der Views der eigenen Posts im Thread.

    Besonderer Dank an arianekonzepte auf Bluesky: durch den Hinweis darauf, dass mein Buildinpublic-Thread auf Bluesky über eine Lösung in huginn nicht nur übersichtlich war, sondern auch von einem kleinen Themenbot geboostet wurde, kam ich erst wieder auf diese Möglichkeit der Ordnung auf Social Media.

  • Happy Birthday, ASCA! Mein Social-Wall-Projekt wird ein Jahr alt

    Heute vor einem Jahr habe ich das damals noch unter https://social.mariobreskic.de/ erreichbare Projekt namens ASCA, Automated Social Content Archiver, gestartet: um eine Idee von mir umzusetzen, die es ermöglicht, dass man meine Posts auf meinen zur Zeit aktiveren Social-Media-Accounts auch dann lesen (und per RSS abonnieren, crawlen, weiterverarbeiten, mit AIs durchsuchen, usw.) kann, wenn man weder einen Account auf diesen Seiten hat, noch einen Account haben will.

    Liberate the Post! sozusagen.

    Und jetzt, nach einem Jahr, finde ich zwischen Familientreffen, der fortlaufenden Bedrohung durch Hornissen und Wespen, kurz Zeit, um mir die ersten Beiträge anzuschauen, die auf meiner Social Wall veröffentlicht wurden. Ich erwarte einige Posts, die wenig mehr aussagen, als „Test“, „Noch ein Test“ und „Dieser Test muss jetzt auch noch sein“.

    Partyhüte aufgesetzt? Cool, aber so spannend wird das nicht.

    Die ersten drei Posts

    Tag 0, einen Tag vor dem offiziellen Launch:

    This is a test without a title, but with a single tag.
    For a thing I am doing.

    Ursprünglich gepostet auf meinem Sideblog auf Tumblr, war dies schlicht der erste gelungene Test der Übertragung von Inhalten aus einer Social-Media-Plattform nach WordPress. Und welchen Tag hat es einfach nicht mitgenommen? Beim Blick auf die Quelle auf Tumblr, steht er da: #strength is performance.
    Was ich mit diesem Tag gemeint habe? Keine Ahnung mehr, der Kontext erschließt sich mir nicht mehr.1

    Und so folgt dann auch schon der erste richtige Beitrag, der auch mit Hashtags übertragen wurde, schon am nächsten Tag, mit dem Launch am 24. August 2024:

    I am currently working on a sort of a public facing backbone archive for my social media, using WordPress.
    The main idea is to collect all of my social media posts from now on in one single place, so that we can have a social wall of basically everything.
    I think that each social media website provides a certain tone, which can add to our work.
    It basically works like this:
    my own posts on Tumblr (and maybe soon Instagram and Twitter, too) are being copied as native WordPress posts.
    Stay tuned, I think I will bash this one out in the next few hours.
    If all works out properly, you can watch things pop up on https://social.­mariobreskic.­de/ over the next few months.
    I have a need to build and learn in public. Always have. So I will do just that.
    Next on the list: my own website, and making a tumblr theme which works for me (I need a sidebar to just add tags to).
    I expect a lot of tweaking, in general, since what I want and need is usually something I can build with what I have.

    https://codeandcanvas.tumblr.com/post/759711141568102401/i-am-currently-working-on-a-sort-of-a-public

    Interessant, zu diesem Zeitpunkt waren Twitter und Instagram noch gar nicht eingebunden, aber ich dachte schon daran, wie ich sie einbinden würde. Wusste ich gar nicht mehr! Cool, ich lese meine eigenen öffentlichen Aufzeichnungen von vor einem Jahr.

    Und am gleichen Tag folgte dann auch schon der erste Tweet, der in meiner Social Wall zugänglich gemacht wurde. Und damals war noch ein anderer Hashtag der Trigger für das selektive Speichern? ACAS und nicht, wie später, ASCA. Könnte aber auch ein Fehlerteufel gewesen sein.

    I am currently building a public archive of my social media posts using #automation.
    You can watch the progress here https://social.­mariobreskic.­de/ #acas #buildinpublic #LearnInPublic

    https://x.com/MarioBreskic/status/1827384913391129073

    Auch damals lag mir diese Art des öffentlich dokumentierten Arbeitens, die Tags #buildinpublic und #learninpublic sind mir so vertraut wie #gläsernewerkstatt und #gläserneslabor, aber #learninpublic hab ich in letzter Zeit eher selten verwendet (was ich übrigens auch einfach auf meiner Social Wall selber nachschlagen kann).

    Wie es weiterging

    So kamen dann auch mit der Zeit Instagram, Threads, Mastodon und auch Bluesky hinzu, und mir wurde klar, dass ich bei meinen eigenen Posts langsam in Richtung steigender Ansprüche ging, damit es wirklich wert ist, dass jemand seine Zeit hergibt, um von mir etwas zu sehen, zu lesen.

    Auch gab es dann noch ein paar Umstellungen und Veränderungen, und auch die Subdomain wanderte von social.mariobreskic.de zu socialwall.mariobreskic.de, aber das waren noch die kleineren Veränderungen. Und jetzt, ein Jahr nach Launch, bin ich froh darüber, dass dieses Projekt einfach existiert. Es ist da. Es selbst persistiert Social Media und darum ging es mir: wenn jemand einfach keinen Bock auf Social Media hat (und ich kann es so gut verstehen), aber trotzdem einfach und dass ohne sich irgendwo einzuloggen nötig ist, sehen will, was ich da so treibe, kann er das seit einem Jahr ganz einfach auf meiner Social Wall auch tun.

    Wohin es geht

    Das steht noch in den Sternen für ASCA. Und für mich auch. Danke fürs Mitfeiern und danke für so manch gutes Wort an passender Stell’.

    Jetzt auf „Veröffentlichen“ klicken und feiern gehen.

    1. Ab morgen, den 25. August 2025 um 7:59 Uhr kann man unter dieser URL https://www.mariobreskic.de/blog/persisting-context-kontext-erhalten-mit-index-posts/ zu dem Thema des Kontextes auf Social Media mehr lesen, oder aber auf ein paar meiner Social-Media-Accounts schon schauen, wozu ich die Möglichkeit eines gepinnten Beitrages verwende. ↩︎
  • Update 5: Change Notes 11.08.2025

    Update 5: Change Notes 11.08.2025

    Change Notes beziehen sich auf Arbeiten seit den letzten Change Notes. Ich war um Vollständigkeit bemüht, kann aber aus Gründen der Sicherheit nicht ins Detail gehen.

    AEO/SEO1

    Datenschutzerklärung überarbeitet

    Design

    Informationsergonomie, menschorientiertes Design und Signaletik

    • Linkwarden als Speicher für Links aus Artikeln aktiviert, doppelte Archivierung in Linkwarden (nicht öffentlich) und archive.org (öffentlich) beugt Link rot vor (siehe Snippet hier)
    • Redirects für alle Veränderungen implementiert
    • Maschinenlesbar bedeutet für Menschen lesbar (fortlaufende Arbeit, die Schnittstellen unter AEO/SEO erleichtern beide Lese-Arten)
    • Verschiedene Scripts hinzugefügt, die informationsdichten Inhalt von optionalem/ornamentalem Inhalt trennen (beispielsweise werden in der Sitemap nur die Inhalte berücksichtigt, die nicht durch Automatisierung und Integration entstehen)
    • Code-Snippets haben jetzt einen Button, mit dem man den ganzen Code mit nur einem Klick kopieren kann
    • Artikel und Seiten zeigen an, wann sie als letztes Mal nach der Veröffentlichung bearbeitet wurden

    Kategorien und Seiten, Navigation

    • Neue Kategorien: Macht (ehemals Baut), Baukasten (ehemals eine einzelne Seite namens Toolbox), Kategorie Status wurde zum Zweck der Übersichtlichkeit aufgelöst
    • Breadcrumb-Navigation hinzugefügt
    • Suche verbessert: Suchergebnisse werden hervorgehoben und ihre Anzahl angezeigt
    • Weniger Kurznachrichten-Kategorien auf der Landing Page (experimentell)

    Projekte

    Design-Informationsdienst (auch bekannt als Grafikdesignfeed 2.0)

    Momentaner Umfang des Netzwerks: auf Bluesky, Mastodon, Threads und Twitter werden dreimal täglich die interessantesten Artikel gepostet

    Mastodon

    • Anbindung an das Fediverse verbessert4
    • Suche mittels Elasticsearch optimiert

    Social Wall


    1. Zu AEO „Answer Engine Optimization“ siehe auch dieser Thread ↩︎
    2. Über humans.txt: About humans.txt ↩︎
    3. Siehe mehr dazu hier: Getting Started with llms.txt ↩︎
    4. Durch Anpassung der nginx-Direktiven wurden sowohl die Reichweite meiner Instanz daramtisch erhöht, als auch fehlerhafte Anfragen mittels .well-known beseitigt. Aktuelle Direktiven teile ich gerne auf Anfrage auf meiner Website hier ↩︎