Unverified Commit 2d4cd9c0 authored by ChanHaeng Lee's avatar ChanHaeng Lee Committed by GitHub
Browse files

Merge branch 'fedify-dev:main' into issue#310

parents d8c44cdf d98a4df1
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -426,6 +426,7 @@ jobs:
          | @fedify/nestjs   | ${{ steps.versioning.outputs.version }} |                             | [npm][npm:@fedify/nestjs]   |
          | @fedify/postgres | ${{ steps.versioning.outputs.version }} | [JSR][jsr:@fedify/postgres] | [npm][npm:@fedify/postgres] |
          | @fedify/redis    | ${{ steps.versioning.outputs.version }} | [JSR][jsr:@fedify/redis]    | [npm][npm:@fedify/redis]    |
          | @fedify/testing  | ${{ steps.versioning.outputs.version }} | [JSR][jsr:@fedify/testing]  | [npm][npm:@fedify/testing]  |

          [jsr:@fedify/fedify]: https://jsr.io/@fedify/fedify@${{ steps.versioning.outputs.version }}
          [npm:@fedify/fedify]: https://www.npmjs.com/package/@fedify/fedify/v/${{ steps.versioning.outputs.short_version }}
@@ -441,6 +442,8 @@ jobs:
          [npm:@fedify/postgres]: https://www.npmjs.com/package/@fedify/postgres/v/${{ steps.versioning.outputs.short_version }}
          [jsr:@fedify/redis]: https://jsr.io/@fedify/redis@${{ steps.versioning.outputs.version }}
          [npm:@fedify/redis]: https://www.npmjs.com/package/@fedify/redis/v/${{ steps.versioning.outputs.short_version }}
          [jsr:@fedify/testing]: https://jsr.io/@fedify/testing@${{ steps.versioning.outputs.version }}
          [npm:@fedify/testing]: https://www.npmjs.com/package/@fedify/testing/v/${{ steps.versioning.outputs.short_version }}
        pr-number: ${{ github.event.pull_request.number }}
        comment-tag: publish

+51 −6
Original line number Diff line number Diff line
@@ -24,6 +24,16 @@ the versioning.
    All packages now follow the same version number and are released together.
    Previously, each package had independent versioning.

 -  Added mock classes for `Federation` and `Context` interfaces to improve
    testability without requiring a real federation server setup. The mock
    classes track all sent activities with metadata and support all standard
    Fedify patterns including custom path registration and multiple activity
    type listeners.  [[#197], [#283] by Lee ByeongJun]

     -  Added `@fedify/testing` package.
     -  Added `MockFederation` class.
     -  Added `MockContext` class.

 -  Key–value stores now optionally support CAS (compare-and-swap) operation
    for atomic updates.  This is useful for implementing optimistic locking
    and preventing lost updates in concurrent environments.
@@ -72,6 +82,9 @@ the versioning.
        in the WebFinger request.
     -  The `--allow-private-address` or `-p` option allows looking up
        WebFinger information for private addresses (e.g., `localhost`).
     -  The `--max-redirection` option allows uses to specify the maximum
        number of redirects to follow when performing WebFinger lookups.
        [[#311], [#328] by KeunHyeong Park]

 -  Added `--dry-run` option to `fedify init` command.  This option allows users
    to preview what files and configurations would be created without actually
@@ -87,20 +100,40 @@ the versioning.
     -  Added `@fedify/nestjs` package.
     -  Added `FedifyModule` for integrating Fedify into NestJS applications.

 -  Added `-o`/`--output` option to `fedify lookup` command. This option allows
    users to save retrieved lookup results to specified path.
    [[#261], [#321] by Jiwon Kwon]

[#168]: https://github.com/fedify-dev/fedify/issues/168
[#197]: https://github.com/fedify-dev/fedify/issues/197
[#248]: https://github.com/fedify-dev/fedify/issues/248
[#260]: https://github.com/fedify-dev/fedify/issues/260
[#261]: https://github.com/fedify-dev/fedify/issues/261
[#262]: https://github.com/fedify-dev/fedify/issues/262
[#263]: https://github.com/fedify-dev/fedify/issues/263
[#269]: https://github.com/fedify-dev/fedify/issues/269
[#278]: https://github.com/fedify-dev/fedify/pull/278
[#281]: https://github.com/fedify-dev/fedify/pull/281
[#282]: https://github.com/fedify-dev/fedify/pull/282
[#283]: https://github.com/fedify-dev/fedify/pull/283
[#285]: https://github.com/fedify-dev/fedify/pull/285
[#298]: https://github.com/fedify-dev/fedify/pull/298
[#300]: https://github.com/fedify-dev/fedify/pull/300
[#304]: https://github.com/fedify-dev/fedify/issues/304
[#309]: https://github.com/fedify-dev/fedify/pull/309
[#311]: https://github.com/fedify-dev/fedify/issues/311
[#321]: https://github.com/fedify-dev/fedify/pull/321
[#328]: https://github.com/fedify-dev/fedify/pull/309


Version 1.7.6
-------------

Released on July 24, 2025.

 -  Fixed `doubleKnock()` to properly handle redirects with path-only `Location`
    headers by resolving them relative to the original request URL.
    [[#324] by Fabien O'Carroll]


Version 1.7.5
@@ -186,6 +219,18 @@ Released on June 25, 2025.
[#252]: https://github.com/fedify-dev/fedify/pull/252


Version 1.6.7
-------------

Released on July 24, 2025.

 -  Fixed `doubleKnock()` to properly handle redirects with path-only `Location`
    headers by resolving them relative to the original request URL.
    [[#324] by Fabien O'Carroll]

[#324]: https://github.com/fedify-dev/fedify/pull/324


Version 1.6.6
-------------

cli/lookup.test.ts

0 → 100644
+301 −0
Original line number Diff line number Diff line
import { Activity, Note } from "@fedify/fedify";
import { assertEquals, assertExists } from "@std/assert";
import { getContextLoader } from "./docloader.ts";
import { createFileStream, writeObjectToStream } from "./lookup.ts";

Deno.test("createFileStream - creates file stream with proper directory creation", async () => {
  const testDir = "./test_output";
  const testFile = `${testDir}/test.json`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const stream = await createFileStream(testFile);
  assertExists(stream);

  const stat = await Deno.stat(testDir);
  assertEquals(stat.isDirectory, true);

  stream.close();

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("createFileStream - works with absolute paths", async () => {
  const testDir = `${Deno.cwd()}/test_output_absolute`;
  const testFile = `${testDir}/test.json`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const stream = await createFileStream(testFile);
  assertExists(stream);

  const stat = await Deno.stat(testDir);
  assertEquals(stat.isDirectory, true);

  stream.close();

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("createFileStream - creates nested directories", async () => {
  const testDir = "./test_output_nested/deep/path";
  const testFile = `${testDir}/test.json`;

  try {
    await Deno.remove("./test_output_nested", { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const stream = await createFileStream(testFile);
  assertExists(stream);

  // Verify nested directories were created
  const stat = await Deno.stat(testDir);
  assertEquals(stat.isDirectory, true);
  stream.close();

  await Deno.remove("./test_output_nested", { recursive: true });
});

Deno.test("createFileStream - writes data correctly", async () => {
  const testDir = "./test_output_write";
  const testFile = `${testDir}/test.txt`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const stream = await createFileStream(testFile);
  const writer = stream.getWriter();

  const testData = new TextEncoder().encode("Hello, World!");
  await writer.write(testData);
  await writer.close();

  const content = await Deno.readTextFile(testFile);
  assertEquals(content, "Hello, World!");

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("createFileStream - truncates existing file", async () => {
  const testDir = "./test_output_truncate";
  const testFile = `${testDir}/test.txt`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  await Deno.mkdir(testDir, { recursive: true });
  await Deno.writeTextFile(testFile, "Old content");

  const stream = await createFileStream(testFile);
  const writer = stream.getWriter();

  const testData = new TextEncoder().encode("New content");
  await writer.write(testData);
  await writer.close();

  // Verify file was truncated and new content written
  const content = await Deno.readTextFile(testFile);
  assertEquals(content, "New content");

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("writeObjectToStream - writes Note object with default options", {
  sanitizeResources: false,
}, async () => {
  const testDir = "./test_output_note";
  const testFile = `${testDir}/note.txt`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const note = new Note({
    id: new URL("https://example.com/notes/1"),
    content: "Hello, fediverse!",
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
    output: testFile,
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(note, options, contextLoader);

  const content = await Deno.readTextFile(testFile);
  assertExists(content);
  assertEquals(content.includes("Hello, fediverse!"), true);
  assertEquals(content.includes("Note"), true);

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("writeObjectToStream - writes Activity object in raw JSON-LD format", async () => {
  const testDir = "./test_output_activity";
  const testFile = `${testDir}/activity.json`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const activity = new Activity({
    id: new URL("https://example.com/activities/1"),
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
    output: testFile,
    raw: true,
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(activity, options, contextLoader);

  // Verify file exists and contains JSON-LD
  const content = await Deno.readTextFile(testFile);

  assertExists(content);
  assertEquals(content.includes("@context"), true);
  assertEquals(content.includes("id"), true);

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("writeObjectToStream - writes object in compact JSON-LD format", async () => {
  const testDir = "./test_output_compact";
  const testFile = `${testDir}/compact.json`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const note = new Note({
    id: new URL("https://example.com/notes/1"),
    content: "Test note",
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
    output: testFile,
    compact: true,
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(note, options, contextLoader);

  // Verify file exists and contains compacted JSON-LD
  const content = await Deno.readTextFile(testFile);
  assertExists(content);
  assertEquals(content.includes("Test note"), true);

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("writeObjectToStream - writes object in expanded JSON-LD format", async () => {
  const testDir = "./test_output_expand";
  const testFile = `${testDir}/expand.json`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const note = new Note({
    id: new URL("https://example.com/notes/1"),
    content: "Test note for expansion",
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
    output: testFile,
    expand: true,
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(note, options, contextLoader);

  const content = await Deno.readTextFile(testFile);
  assertExists(content);
  assertEquals(content.includes("Test note for expansion"), true);

  await Deno.remove(testDir, { recursive: true });
});

Deno.test("writeObjectToStream - writes to stdout when no output file specified", async () => {
  const note = new Note({
    id: new URL("https://example.com/notes/1"),
    content: "Test stdout note",
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(note, options, contextLoader);
});

Deno.test("writeObjectToStream - handles empty content properly", async () => {
  const testDir = "./test_output_empty";
  const testFile = `${testDir}/empty.txt`;

  try {
    await Deno.remove(testDir, { recursive: true });
  } catch {
    // Ignore if doesn't exist
  }

  const note = new Note({
    id: new URL("https://example.com/notes/1"),
  });

  const options = {
    firstKnock: "rfc9421" as const,
    separator: "----",
    output: testFile,
  };

  const contextLoader = await getContextLoader({});

  await writeObjectToStream(note, options, contextLoader);

  const content = await Deno.readTextFile(testFile);
  assertExists(content);
  assertEquals(content.includes("Note"), true);

  await Deno.remove(testDir, { recursive: true });
});
+115 −20
Original line number Diff line number Diff line
@@ -15,15 +15,114 @@ import {
  traverseCollection,
} from "@fedify/fedify";
import { getLogger } from "@logtape/logtape";
import { dirname, isAbsolute, resolve } from "@std/path";
import ora from "ora";
import { getContextLoader, getDocumentLoader } from "./docloader.ts";
import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts";
import { printJson } from "./utils.ts";

const logger = getLogger(["fedify", "cli", "lookup"]);

const sigSpec = new EnumType(["draft-cavage-http-signatures-12", "rfc9421"]);

interface CommandOptions {
  authorizedFetch?: boolean;
  firstKnock: "draft-cavage-http-signatures-12" | "rfc9421";
  traverse?: boolean;
  suppressErrors?: boolean;
  raw?: boolean;
  compact?: boolean;
  expand?: boolean;
  userAgent?: string;
  separator: string;
  output?: string;
}

export async function createFileStream(
  outputPath: string,
): Promise<WritableStream> {
  try {
    const filepath = isAbsolute(outputPath)
      ? outputPath
      : resolve(Deno.env.get("PWD") || Deno.cwd(), outputPath);

    const parentDir = dirname(filepath);
    await Deno.mkdir(parentDir, { recursive: true });

    const file = await Deno.open(filepath, {
      write: true,
      create: true,
      truncate: true,
    });

    return new WritableStream({
      write: (chunk) => file.write(chunk).then(() => {}),
      close: () => file.close(),
      abort: (reason) => {
        file.close();
        throw reason;
      },
    });
  } catch (err) {
    const spinner = ora({
      text: `Failed to write output to ${colors.red(outputPath)}.`,
      discardStdin: false,
    });
    spinner.fail();
    console.error(`Error: ${String(err)}`);

    if (err instanceof Deno.errors.PermissionDenied) {
      console.error(
        "Permission denied. Try running with proper permissions.",
      );
    } else if (err instanceof Deno.errors.NotFound) {
      console.error("Path does not exist or is invalid.");
    } else if (err instanceof Deno.errors.IsADirectory) {
      console.error("The specified path is a directory, not a file.");
    }
    Deno.exit(1);
  }
}

export async function writeObjectToStream(
  object: Object | Link,
  options: CommandOptions,
  contextLoader: DocumentLoader,
): Promise<void> {
  const stream = options.output
    ? await createFileStream(options.output)
    : Deno.stdout.writable;

  const writer = stream.getWriter();

  try {
    let content;

    if (options.raw) {
      content = await object.toJsonLd({ contextLoader });
    } else if (options.compact) {
      content = await object.toJsonLd({ format: "compact", contextLoader });
    } else if (options.expand) {
      content = await object.toJsonLd({ format: "expand", contextLoader });
    } else {
      content = object;
    }

    content = Deno.inspect(content, {
      colors: !(options.output),
    });

    const encoder = new TextEncoder();
    const bytes = encoder.encode(content + "\n");

    await writer.write(bytes);
  } finally {
    writer.releaseLock();
    if (options.output) {
      await stream.close();
    }
  }
}

export const command = new Command()
  .type("sig-spec", sigSpec)
  .arguments("<...urls:string>")
@@ -65,6 +164,10 @@ export const command = new Command()
      "collection items.",
    { default: "----" },
  )
  .option(
    "-o, --output <file>",
    "Specify the output file path.",
  )
  .action(async (options, ...urls: string[]) => {
    if (urls.length < 1) {
      console.error("At least one URL or actor handle must be provided.");
@@ -75,6 +178,7 @@ export const command = new Command()
      );
      Deno.exit(1);
    }

    const spinner = ora({
      text: `Looking up the ${
        options.traverse ? "collection" : urls.length > 1 ? "objects" : "object"
@@ -143,26 +247,11 @@ export const command = new Command()
        },
      );
    }

    spinner.text = `Looking up the ${
      options.traverse ? "collection" : urls.length > 1 ? "objects" : "object"
    }...`;

    async function printObject(object: Object | Link): Promise<void> {
      if (options.raw) {
        printJson(await object.toJsonLd({ contextLoader }));
      } else if (options.compact) {
        printJson(
          await object.toJsonLd({ format: "compact", contextLoader }),
        );
      } else if (options.expand) {
        printJson(
          await object.toJsonLd({ format: "expand", contextLoader }),
        );
      } else {
        console.log(object);
      }
    }

    if (options.traverse) {
      const url = urls[0];
      const collection = await lookupObject(url, {
@@ -198,8 +287,8 @@ export const command = new Command()
            suppressError: options.suppressErrors,
          })
        ) {
          if (i > 0) console.log(options.separator);
          printObject(item);
          if (!options.output && i > 0) console.log(options.separator);
          await writeObjectToStream(item, options, contextLoader);
          i++;
        }
      } catch (error) {
@@ -218,6 +307,7 @@ export const command = new Command()
        Deno.exit(1);
      }
      spinner.succeed("Successfully fetched all items in the collection.");

      await server?.close();
      Deno.exit(0);
    }
@@ -254,7 +344,7 @@ export const command = new Command()
          success = false;
        } else {
          spinner.succeed(`Fetched object: ${colors.green(url)}.`);
          printObject(object);
          await writeObjectToStream(object, options, contextLoader);
          if (i < urls.length - 1) {
            console.log(options.separator);
          }
@@ -274,6 +364,11 @@ export const command = new Command()
    if (!success) {
      Deno.exit(1);
    }
    if (success && options.output) {
      spinner.succeed(
        `Successfully wrote output to ${colors.green(options.output)}.`,
      );
    }
  });

// cSpell: ignore sigspec
+12 −1
Original line number Diff line number Diff line
import { Command } from "@cliffy/command";
import { Command, ValidationError } from "@cliffy/command";
import { toAcctUrl } from "@fedify/fedify/vocab";
import { lookupWebFinger } from "@fedify/fedify/webfinger";
import ora from "ora";
@@ -17,7 +17,18 @@ export const command = new Command()
    "-p, --allow-private-address",
    "Allow private IP addresses in the URL.",
  )
  .option(
    "--max-redirection <maxRedirection:integer>",
    "Maximum number of redirections to follow.",
    { default: 5 },
  )
  .action(async (options, ...resources: string[]) => {
    if (options.maxRedirection < 0) { // Validate maxRedirection option
      throw new ValidationError(
        `Option --max-redirection must be greater than or equal to 0, but got ${options.maxRedirection}.`,
      );
    }

    for (const resource of resources) {
      const spinner = ora({ // Create a spinner for the lookup process
        text: `Looking up WebFinger for ${resource}`,
Loading