Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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 @@ -58,6 +58,7 @@
"lume",
"lumocs",
"metas",
"metavar",
"microblog",
"microblogging",
"Minhee",
Expand All @@ -69,6 +70,7 @@
"multitenancy",
"Nexkey",
"nodeinfo",
"optique",
"phensley",
"Pico",
"Pixelfed",
Expand Down Expand Up @@ -105,6 +107,7 @@
"unfollows",
"urlpattern",
"uuidv7",
"valueparser",
"Vinxi",
"vitepress",
"vtsls",
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/mod.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { or } from "@optique/core";
import { run } from "@optique/run";
import { lookupCommand, runLookup } from "./lookup.ts";
import { runWebFinger, webFingerCommand } from "./webfinger.ts";
import { initCommand, runInit } from "./init.ts";
import { inboxCommand, runInbox } from "./inbox.ts";
import { initCommand, runInit } from "./init.ts";
import { lookupCommand, runLookup } from "./lookup.ts";
import { nodeInfoCommand, runNodeInfo } from "./nodeinfo.ts";
import { runTunnel, tunnelCommand } from "./tunnel.ts";
import { runWebFinger, webFingerCommand } from "./webfinger.ts";

const command = or(
initCommand,
Expand All @@ -28,7 +28,7 @@ async function main() {
await runLookup(result);
}
if (result.command === "webfinger") {
runWebFinger(result);
await runWebFinger(result);
}
Comment thread
2chanhaeng marked this conversation as resolved.
if (result.command === "inbox") {
runInbox(result);
Expand Down
79 changes: 79 additions & 0 deletions packages/cli/src/webfinger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { parse } from "@optique/core/parser";
import { assertEquals, assertObjectMatch } from "@std/assert";
import test from "node:test";
import { lookupSingleWebFinger, webFingerCommand } from "./webfinger.ts";

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

test("Test webFingerCommand", () => {
// Resources only
const argsWithResourcesOnly = [COMMAND, ...RESOURCES];
assertEquals(
parse(webFingerCommand, argsWithResourcesOnly),
{
success: true,
value: {
debug: false,
command: COMMAND,
resources: RESOURCES,
allowPrivateAddresses: undefined,
maxRedirection: 5,
userAgent: undefined,
},
},
);
// With options
const maxRedirection = 10;
assertEquals(
parse(webFingerCommand, [
...argsWithResourcesOnly,
"-d",
"-u",
USER_AGENT,
"--max-redirection",
String(maxRedirection),
"--allow-private-address",
]),
{
success: true,
value: {
debug: true,
command: COMMAND,
resources: RESOURCES,
allowPrivateAddresses: true,
maxRedirection,
userAgent: USER_AGENT,
},
},
);
// Wrong option
assertObjectMatch(
parse(webFingerCommand, [...argsWithResourcesOnly, "-Q"]),
{ success: false },
);
// Wrong option value
assertObjectMatch(
parse(
webFingerCommand,
[...argsWithResourcesOnly, "--max-redirection", "-10"],
),
{ success: false },
);
});

test("Test lookupSingleWebFinger", async () => {
const aliases = (await Array.fromAsync(
RESOURCES,
(resource) => lookupSingleWebFinger({ resource }),
)).map((w) => w?.aliases?.[0]);
assertEquals(aliases, ALIASES);
});
148 changes: 144 additions & 4 deletions packages/cli/src/webfinger.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,50 @@
import { type ResourceDescriptor, 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,
merge,
message,
multiple,
object,
option,
optional,
string,
withDefault,
} from "@optique/core";
import ora from "ora";
import { debugOption } from "./globals.ts";
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 @@ -19,16 +54,121 @@ export const webFingerCommand = command(
resources: multiple(argument(string({ metavar: "RESOURCE" }), {
description: message`WebFinger resource(s) to look up.`,
})),
userAgent,
allowPrivateAddresses,
maxRedirection,
}),
debugOption,
),
{
description: message`Look up WebFinger resources.`,
description:
message`Look up WebFinger resources. The argument can be multiple.`,
},
);

export function runWebFinger(
command: InferValue<typeof webFingerCommand>,
export async function runWebFinger(
{ command: _, resources, ...options }: InferValue<typeof webFingerCommand>,
) {
console.debug(command);
await Array.fromAsync(
resources.map((resource) => ({ resource, ...options })),
spinnerWrapper(lookupSingleWebFinger),
);
}

export async function lookupSingleWebFinger<
T extends LookupWebFingerOptions & { resource: string },
>({ resource, ...options }: T): Promise<ResourceDescriptor> {
const url = convertUrlIfHandle(resource);
const webFinger = await lookupWebFinger(url, options) ??
new NotFoundError(resource).throw();
return webFinger;
}

function spinnerWrapper<F extends typeof lookupSingleWebFinger>(
func: (...args: Parameters<F>) => ReturnType<F>,
) {
return async (...args: Parameters<F>) => {
const spinner = ora({
text: `Looking up WebFinger for ${args[0]}`,
discardStdin: false,
}).start();
try {
const result = await func(...args);
spinner.succeed(`WebFinger found for ${args[0]}:`);
console.log(formatObject(result, undefined, true));
} catch (error) {
spinner.fail(getErrorMessage(args[0].resource, error));
}
};
}

const getErrorMessage = (resource: string, error: unknown): string =>
error instanceof InvalidHandleError
? `Invalid handle format: ${error.handle}`
: error instanceof NotFoundError
? `Resource not found: ${error.resource}`
: error instanceof Error
? `Error looking up WebFinger for ${resource}: ${error.message}`
: `Error looking up WebFinger for ${resource}: ${error}`;

/**
* 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