Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"lume",
"lumocs",
"metas",
"metavar",
"microblog",
"microblogging",
"Minhee",
Expand All @@ -67,6 +68,7 @@
"multitenancy",
"Nexkey",
"nodeinfo",
"optique",
"phensley",
"Pico",
"Pixelfed",
Expand Down Expand Up @@ -101,6 +103,7 @@
"unfollows",
"urlpattern",
"uuidv7",
"valueparser",
"Vinxi",
"vitepress",
"vtsls",
Expand Down
84 changes: 84 additions & 0 deletions packages/cli/src/webfinger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { lookupWebFinger } from "@fedify/fedify/webfinger";
import { run } from "@optique/run";
Comment thread
dahlia marked this conversation as resolved.
Outdated
import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert";
import test from "node:test";
import { convertHandleToUrl, webFingerCommand } from "./webfinger.ts";

const COMMAND = "webfinger";
const RESOURCES = ["@hongminhee@hackers.pub", "@fedify@hollo.social"];
const USER_AGENT = "MyUserAgent/1.0";
const ALIASES = [
"https://hackers.pub/ap/actors/019382d3-63d7-7cf7-86e8-91e2551c306c",
"https://hollo.social/@fedify",
];

test("webFingerCommand", () => {
// Resources only
const argsWithResourcesOnly = [COMMAND, ...RESOURCES];
assertEquals(
run(webFingerCommand, {
args: argsWithResourcesOnly,
}),
{
command: COMMAND,
resources: RESOURCES,
allowPrivateAddresses: undefined,
maxRedirection: 5,
userAgent: undefined,
},
);
// With options
const maxRedirection = 10;
assertStrictEquals(
run(webFingerCommand, {
args: [
...argsWithResourcesOnly,
"-u",
USER_AGENT,
"--max-redirection",
String(maxRedirection),
"--allow-private-addresses",
],
}),
{
command: COMMAND,
resources: RESOURCES,
allowPrivateAddresses: true,
maxRedirection,
userAgent: USER_AGENT,
},
);
// Wrong option
assertThrows(() =>
run(
webFingerCommand,
{
args: [
...argsWithResourcesOnly,
"-Q",
],
},
)
);
// Wrong option value
assertThrows(() =>
run(
webFingerCommand,
{
args: [
...argsWithResourcesOnly,
"--max-redirection",
"-10",
],
},
)
);
});

test("Lookup webfinger", async () => {
const aliases = (await Array.fromAsync(
RESOURCES.map(convertHandleToUrl),
(h) => lookupWebFinger(h),
)).map((w) => w?.aliases?.[0]);
assertStrictEquals(aliases, ALIASES);
});
139 changes: 136 additions & 3 deletions packages/cli/src/webfinger.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,50 @@
import { toAcctUrl } from "@fedify/fedify";
import {
lookupWebFinger,
type LookupWebFingerOptions,
} from "@fedify/fedify/webfinger";
import { getLogger } from "@logtape/logtape";
import {
argument,
command,
constant,
flag,
type InferValue,
integer,
message,
multiple,
object,
option,
optional,
string,
withDefault,
} from "@optique/core";
import ora from "ora";
import { formatObject } from "./utils.ts";

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

const userAgent = optional(option(
"-u",
"--user-agent",
string({ metavar: "USER_AGENT" }),
{
description: message`The custom User-Agent header value.`,
},
));

const allowPrivateAddresses = optional(flag("-p", "--allow-private-address", {
description: message`Allow private IP addresses in the URL.`,
}));

const maxRedirection = withDefault(
option(
"--max-redirection",
integer({ min: 0 }),
{ description: message`Maximum number of redirections to follow.` },
),
5,
);

export const webFingerCommand = command(
"webfinger",
Expand All @@ -16,14 +53,110 @@ export const webFingerCommand = command(
resources: multiple(argument(string({ metavar: "RESOURCE" }), {
description: message`WebFinger resource(s) to look up.`,
})),
userAgent,
allowPrivateAddresses,
maxRedirection,
}),
{
description: message`Look up WebFinger resources.`,
description:
message`Look up WebFinger resources. The argument can be multiple.`,
},
);

export function runWebFinger(
command: InferValue<typeof webFingerCommand>,
{ command: _, resources, ...options }: InferValue<typeof webFingerCommand>,
) {
console.debug(command);
lookupWebFingers(options, resources);
}

const lookupWebFingers = async (
options: LookupWebFingerOptions,
resources: readonly string[],
) => {
for (const resource of resources) {
const spinner = ora({ // Create a spinner for the lookup process
text: `Looking up WebFinger for ${resource}`,
discardStdin: false,
}).start();
try {
const url = convertUrlIfHandle(resource); // Convert resource to URL
const webFinger = await lookupWebFinger(url, options) ?? // Look up WebFinger
new NotFoundError(resource).throw(); // throw NotFoundError if not found

spinner.succeed(`WebFinger found for ${resource}:`); // Succeed the spinner
console.log(formatObject(webFinger, undefined, true)); // Print the WebFinger
} catch (error) {
if (error instanceof InvalidHandleError) { // If the handle format is invalid,
spinner.fail(`Invalid handle format: ${error.handle}`); // log error message with handle
} else if (error instanceof NotFoundError) { // If the resource is not found,
spinner.fail(`Resource not found: ${error.resource}`); // log not found message
} else if (error instanceof Error) {
spinner.fail( // For other errors, log the error message
`Error looking up WebFinger for ${resource}: ${error}`,
);
Comment thread
2chanhaeng marked this conversation as resolved.
Outdated
}
}
}
};

/**
* Converts a handle or URL to a URL object.
* If the input is a valid URL, it returns the URL object.
* If the input is a handle in the format `@username@domain`, it converts it to a URL.
* @param handleOrUrl The handle or URL to convert.
* @returns A URL object representing the handle or URL.
*/
function convertUrlIfHandle(handleOrUrl: string): URL {
try {
return new URL(handleOrUrl); // Try to convert the input to a URL
} catch {
return convertHandleToUrl(handleOrUrl); // If it fails, treat it as a handle
}
}

/**
* Custom error class for invalid handle formats.
* @param {string} handle The invalid handle that caused the error.
* @extends {Error}
*/
class InvalidHandleError extends Error {
constructor(public handle: string) {
super(`Invalid handle format: ${handle}`);
this.name = "InvalidHandleError";
}
throw(): never {
throw this;
}
}

/**
* Custom error class for not found resources.
* @param {string} resource The resource that was not found.
* @extends {Error}
*/
class NotFoundError extends Error {
constructor(public resource: string) {
super(`Resource not found: ${resource}`);
this.name = "NotFoundError";
}
throw(): never {
throw this;
}
}

/**
* Converts a handle in the format `@username@domain` to a URL.
* The resulting URL will be in the format `https://domain/@username`.
* @param handle The handle to convert, in the format `@username@domain`.
* @returns A URL object representing the handle.
* @throws {Error} If the handle format is invalid.
* @example
* ```ts
* const url = convertHandleToUrl("@username@domain.com");
* console.log(url.toString()); // "https://domain.com/@username"
* ```
*/
export function convertHandleToUrl(handle: string): URL {
return toAcctUrl(handle) ?? // Convert the handle to a URL
new InvalidHandleError(handle).throw(); // or throw an error if invalid
}
Loading