mirror of
https://github.com/Sevichecc/raycast-akkoma-extension.git
synced 2025-04-30 22:49:30 +08:00
refactor: api&oauth
This commit is contained in:
parent
24f904a60f
commit
f50b45aaed
5 changed files with 101 additions and 116 deletions
92
src/api.ts
92
src/api.ts
|
@ -12,11 +12,31 @@ import {
|
||||||
UploadAttachResponse,
|
UploadAttachResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { client } from "./oauth";
|
import { client } from "./oauth";
|
||||||
|
import { RequestInit, Response } from "node-fetch";
|
||||||
|
|
||||||
export const fetchToken = async (params: URLSearchParams, errorMessage: string): Promise<OAuth.TokenResponse> => {
|
|
||||||
const { instance } = getPreferenceValues<Preference>();
|
const { instance } = getPreferenceValues<Preference>();
|
||||||
|
|
||||||
const response = await fetch(`https://${instance}/oauth/token`, {
|
const CONFIG = {
|
||||||
|
tokenUrl: "/oauth/token",
|
||||||
|
appUrl: "/api/v1/apps",
|
||||||
|
statusesUrl: "/api/v1/statuses",
|
||||||
|
verifyCredentialsUrl: "/api/v1/accounts/verify_credentials",
|
||||||
|
mediaUrl: "/api/v1/media/",
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiUrl = (instance: string, path: string): string => `https://${instance}${path}`;
|
||||||
|
|
||||||
|
const fetchWithAuth = async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||||
|
const tokenSet = await client.getTokens();
|
||||||
|
const headers = {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: `Bearer ${tokenSet?.accessToken}`,
|
||||||
|
};
|
||||||
|
return fetch(url, { ...options, headers });
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchToken = async (params: URLSearchParams, errorMessage: string): Promise<OAuth.TokenResponse> => {
|
||||||
|
const response = await fetch(apiUrl(instance, CONFIG.tokenUrl), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: params,
|
body: params,
|
||||||
});
|
});
|
||||||
|
@ -25,14 +45,10 @@ export const fetchToken = async (params: URLSearchParams, errorMessage: string):
|
||||||
return (await response.json()) as OAuth.TokenResponse;
|
return (await response.json()) as OAuth.TokenResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createApp = async (): Promise<Credentials> => {
|
const createApp = async (): Promise<Credentials> => {
|
||||||
const { instance } = getPreferenceValues<Preference>();
|
const response = await fetch(apiUrl(instance, CONFIG.appUrl), {
|
||||||
|
|
||||||
const response = await fetch(`https://${instance}/api/v1/apps`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
client_name: "raycast-akkoma-extension",
|
client_name: "raycast-akkoma-extension",
|
||||||
redirect_uris: "https://raycast.com/redirect?packageName=Extension",
|
redirect_uris: "https://raycast.com/redirect?packageName=Extension",
|
||||||
|
@ -46,60 +62,28 @@ export const createApp = async (): Promise<Credentials> => {
|
||||||
return (await response.json()) as Credentials;
|
return (await response.json()) as Credentials;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const postNewStatus = async ({
|
const postNewStatus = async (statusOptions: Partial<Status>): Promise<StatusResponse> => {
|
||||||
status,
|
const response = await fetchWithAuth(apiUrl(instance, CONFIG.statusesUrl), {
|
||||||
visibility,
|
|
||||||
spoiler_text,
|
|
||||||
sensitive,
|
|
||||||
scheduled_at,
|
|
||||||
content_type,
|
|
||||||
media_ids,
|
|
||||||
}: Partial<Status>): Promise<StatusResponse> => {
|
|
||||||
const { instance } = getPreferenceValues<Preference>();
|
|
||||||
const tokenSet = await client.getTokens();
|
|
||||||
|
|
||||||
const response = await fetch(`https://${instance}/api/v1/statuses`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify(statusOptions),
|
||||||
Authorization: `Bearer ${tokenSet?.accessToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
status,
|
|
||||||
visibility,
|
|
||||||
spoiler_text,
|
|
||||||
sensitive,
|
|
||||||
content_type,
|
|
||||||
scheduled_at,
|
|
||||||
media_ids,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to pulish :(");
|
if (!response.ok) throw new Error("Failed to publish :(");
|
||||||
|
|
||||||
return (await response.json()) as StatusResponse;
|
return (await response.json()) as StatusResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAccountInfo = async (): Promise<Account> => {
|
const fetchAccountInfo = async (): Promise<Account> => {
|
||||||
const { instance } = getPreferenceValues<Preference>();
|
const response = await fetchWithAuth(apiUrl(instance, CONFIG.verifyCredentialsUrl), {
|
||||||
const tokenSet = await client.getTokens();
|
|
||||||
|
|
||||||
const response = await fetch(`https://${instance}/api/v1/accounts/verify_credentials`, {
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokenSet?.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch account's info :(");
|
if (!response.ok) throw new Error("Failed to fetch account's info :(");
|
||||||
|
|
||||||
return (await response.json()) as Account;
|
return (await response.json()) as Account;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadAttachment = async ({ file, description }: StatusAttachment): Promise<UploadAttachResponse> => {
|
const uploadAttachment = async ({ file, description }: StatusAttachment): Promise<UploadAttachResponse> => {
|
||||||
const { instance } = getPreferenceValues<Preference>();
|
|
||||||
const tokenSet = await client.getTokens();
|
|
||||||
|
|
||||||
const attachment = fs.readFileSync(file);
|
const attachment = fs.readFileSync(file);
|
||||||
const attachmentData = new File([attachment], file);
|
const attachmentData = new File([attachment], file);
|
||||||
await attachmentData.arrayBuffer();
|
await attachmentData.arrayBuffer();
|
||||||
|
@ -108,15 +92,13 @@ export const uploadAttachment = async ({ file, description }: StatusAttachment):
|
||||||
formData.append("file", attachmentData);
|
formData.append("file", attachmentData);
|
||||||
formData.append("description", description ?? "");
|
formData.append("description", description ?? "");
|
||||||
|
|
||||||
const response = await fetch(`https://${instance}/api/v1/media/`, {
|
const response = await fetchWithAuth(apiUrl(instance, CONFIG.mediaUrl), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokenSet?.accessToken}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Could not upload attechments");
|
if (!response.ok) throw new Error("Could not upload attachments");
|
||||||
|
|
||||||
return (await response.json()) as UploadAttachResponse;
|
return (await response.json()) as UploadAttachResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default { fetchToken, createApp, postNewStatus, fetchAccountInfo, uploadAttachment };
|
||||||
|
|
|
@ -4,11 +4,7 @@ import SimpleCommand from "./simple-status";
|
||||||
import { Status } from "./types";
|
import { Status } from "./types";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
interface CommandProps extends LaunchProps<{ draftValues: Status }> {
|
export default function DetailCommand(props: LaunchProps<{ draftValues: Partial<Status> }>) {
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DetailCommand(props: CommandProps) {
|
|
||||||
const [files, setFiles] = useState<string[]>([]);
|
const [files, setFiles] = useState<string[]>([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
56
src/oauth.ts
56
src/oauth.ts
|
@ -1,6 +1,6 @@
|
||||||
import { LocalStorage, OAuth, getPreferenceValues } from "@raycast/api";
|
import { LocalStorage, OAuth, getPreferenceValues } from "@raycast/api";
|
||||||
import { Preference } from "./types";
|
import { Preference } from "./types";
|
||||||
import { fetchToken, createApp, fetchAccountInfo } from "./api";
|
import apiServer from "./api";
|
||||||
|
|
||||||
export const client = new OAuth.PKCEClient({
|
export const client = new OAuth.PKCEClient({
|
||||||
redirectMethod: OAuth.RedirectMethod.Web,
|
redirectMethod: OAuth.RedirectMethod.Web,
|
||||||
|
@ -10,37 +10,33 @@ export const client = new OAuth.PKCEClient({
|
||||||
description: "Connect to your Akkoma / Pleroma acount",
|
description: "Connect to your Akkoma / Pleroma acount",
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestAccessToken = async (
|
const requestToken = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
clientSecret: string,
|
clientSecret: string,
|
||||||
authRequest: OAuth.AuthorizationRequest,
|
grantType: string,
|
||||||
authCode: string
|
authRequest?: OAuth.AuthorizationRequest,
|
||||||
|
authCode?: string,
|
||||||
|
refreshToken?: string
|
||||||
): Promise<OAuth.TokenResponse> => {
|
): Promise<OAuth.TokenResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append("client_id", clientId);
|
params.append("client_id", clientId);
|
||||||
params.append("client_secret", clientSecret);
|
params.append("client_secret", clientSecret);
|
||||||
params.append("code", authCode);
|
params.append("grant_type", grantType);
|
||||||
params.append("code_verifier", authRequest.codeVerifier);
|
|
||||||
params.append("grant_type", "authorization_code");
|
|
||||||
params.append("redirect_uri", authRequest.redirectURI);
|
|
||||||
|
|
||||||
return await fetchToken(params, "fetch tokens error:");
|
if (grantType === "authorization_code") {
|
||||||
};
|
params.append("code", authCode!);
|
||||||
|
params.append("code_verifier", authRequest!.codeVerifier);
|
||||||
|
params.append("redirect_uri", authRequest!.redirectURI);
|
||||||
|
} else {
|
||||||
|
params.append("refresh_token", refreshToken!);
|
||||||
|
}
|
||||||
|
|
||||||
const refreshToken = async (
|
const tokenResponse = await apiServer.fetchToken(params, `Error while requesting ${grantType} tokens:`);
|
||||||
clientId: string,
|
|
||||||
clientSecret: string,
|
|
||||||
refreshToken: string
|
|
||||||
): Promise<OAuth.TokenResponse> => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append("client_id", clientId);
|
|
||||||
params.append("client_secret", clientSecret);
|
|
||||||
params.append("refresh_token", refreshToken);
|
|
||||||
params.append("grant_type", "refresh_token");
|
|
||||||
|
|
||||||
const tokenResponse = await fetchToken(params, "refresh tokens error:");
|
|
||||||
|
|
||||||
|
if (grantType === "refresh_token") {
|
||||||
tokenResponse.refresh_token = tokenResponse.refresh_token ?? refreshToken;
|
tokenResponse.refresh_token = tokenResponse.refresh_token ?? refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
return tokenResponse;
|
return tokenResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -51,13 +47,15 @@ export const authorize = async (): Promise<void> => {
|
||||||
if (tokenSet?.accessToken) {
|
if (tokenSet?.accessToken) {
|
||||||
if (tokenSet.refreshToken && tokenSet.isExpired()) {
|
if (tokenSet.refreshToken && tokenSet.isExpired()) {
|
||||||
LocalStorage.clear();
|
LocalStorage.clear();
|
||||||
const { client_id, client_secret } = await createApp();
|
const { client_id, client_secret } = await apiServer.createApp();
|
||||||
await client.setTokens(await refreshToken(client_id, client_secret, tokenSet.refreshToken));
|
await client.setTokens(
|
||||||
|
await requestToken(client_id, client_secret, "refresh_token", undefined, undefined, tokenSet.refreshToken)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { client_id, client_secret } = await createApp();
|
const { client_id, client_secret } = await apiServer.createApp();
|
||||||
const authRequest = await client.authorizationRequest({
|
const authRequest = await client.authorizationRequest({
|
||||||
endpoint: `https://${instance}/oauth/authorize`,
|
endpoint: `https://${instance}/oauth/authorize`,
|
||||||
clientId: client_id,
|
clientId: client_id,
|
||||||
|
@ -65,9 +63,13 @@ export const authorize = async (): Promise<void> => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { authorizationCode } = await client.authorize(authRequest);
|
const { authorizationCode } = await client.authorize(authRequest);
|
||||||
await client.setTokens(await requestAccessToken(client_id, client_secret, authRequest, authorizationCode));
|
await client.setTokens(
|
||||||
|
await requestToken(client_id, client_secret, "authorization_code", authRequest, authorizationCode)
|
||||||
|
);
|
||||||
|
|
||||||
const { fqn, avatar_static } = await fetchAccountInfo();
|
const { fqn, avatar_static } = await apiServer.fetchAccountInfo();
|
||||||
await LocalStorage.setItem("account-fqn", fqn);
|
await LocalStorage.setItem("account-fqn", fqn);
|
||||||
await LocalStorage.setItem("account-avator", avatar_static);
|
await LocalStorage.setItem("account-avator", avatar_static);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default { authorize };
|
||||||
|
|
|
@ -13,8 +13,10 @@ import {
|
||||||
LaunchProps,
|
LaunchProps,
|
||||||
} from "@raycast/api";
|
} from "@raycast/api";
|
||||||
|
|
||||||
import { postNewStatus, uploadAttachment } from "./api";
|
import apiServer from "./api";
|
||||||
|
|
||||||
import { AkkomaError, StatusResponse, Preference, Status } from "./types";
|
import { AkkomaError, StatusResponse, Preference, Status } from "./types";
|
||||||
|
|
||||||
import { authorize } from "./oauth";
|
import { authorize } from "./oauth";
|
||||||
|
|
||||||
import VisibilityDropdown from "./components/VisibilityDropdown";
|
import VisibilityDropdown from "./components/VisibilityDropdown";
|
||||||
|
@ -22,13 +24,11 @@ import StatusContent from "./components/StatusContent";
|
||||||
|
|
||||||
const cache = new Cache();
|
const cache = new Cache();
|
||||||
|
|
||||||
type SimpleStatus = Pick<Status, "content_type" | "status" | "spoiler_text" | "visibility">;
|
interface CommandProps extends LaunchProps<{ draftValues: Partial<Status> }> {
|
||||||
|
|
||||||
interface CommandProps extends LaunchProps<{ draftValues: SimpleStatus }> {
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StausForm extends Status {
|
interface StatusForm extends Status {
|
||||||
files: string[];
|
files: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
@ -57,7 +57,7 @@ export default function SimpleCommand(props: CommandProps) {
|
||||||
init();
|
init();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async ({ spoiler_text, status, scheduled_at, visibility, files, description }: StausForm) => {
|
const handleSubmit = async ({ spoiler_text, status, scheduled_at, visibility, files, description }: StatusForm) => {
|
||||||
try {
|
try {
|
||||||
if (!status) throw new Error("You might forget the content, right ? |・ω・)");
|
if (!status) throw new Error("You might forget the content, right ? |・ω・)");
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ export default function SimpleCommand(props: CommandProps) {
|
||||||
|
|
||||||
const mediaIds = await Promise.all(
|
const mediaIds = await Promise.all(
|
||||||
files?.map(async (file) => {
|
files?.map(async (file) => {
|
||||||
const { id} = await uploadAttachment({ file, description });
|
const { id } = await apiServer.uploadAttachment({ file, description });
|
||||||
return id;
|
return id;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -79,7 +79,7 @@ export default function SimpleCommand(props: CommandProps) {
|
||||||
media_ids: mediaIds,
|
media_ids: mediaIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await postNewStatus(newStatus);
|
const response = await apiServer.postNewStatus(newStatus);
|
||||||
|
|
||||||
setStatusInfo(response);
|
setStatusInfo(response);
|
||||||
cache.set("latest_published_status", JSON.stringify(response));
|
cache.set("latest_published_status", JSON.stringify(response));
|
||||||
|
|
35
src/types.ts
35
src/types.ts
|
@ -1,23 +1,25 @@
|
||||||
import type { Icon } from "@raycast/api";
|
import type { Icon, LaunchProps } from "@raycast/api";
|
||||||
|
|
||||||
export interface AkkomaError {
|
export type VisibilityScope = "public" | "unlisted" | "direct" | "private" | "local";
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Preference {
|
export interface Preference {
|
||||||
instance: string;
|
instance: string;
|
||||||
defaultVisibility: VisibilityScope;
|
defaultVisibility: VisibilityScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VisibilityScope = "public" | "unlisted" | "direct" | "private" | "local";
|
|
||||||
|
|
||||||
export interface VisibilityOption {
|
export interface VisibilityOption {
|
||||||
title: string;
|
title: string;
|
||||||
value: VisibilityScope;
|
value: VisibilityScope;
|
||||||
icon: Icon;
|
icon: Icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
export interface AkkomaError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application and Credentials
|
||||||
interface Application {
|
interface Application {
|
||||||
name: string;
|
name: string;
|
||||||
website: string;
|
website: string;
|
||||||
|
@ -33,12 +35,7 @@ export interface Credentials {
|
||||||
vapid_key: string;
|
vapid_key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse {
|
// Statuses
|
||||||
id: number;
|
|
||||||
created_at: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Poll {
|
interface Poll {
|
||||||
expired_in: number;
|
expired_in: number;
|
||||||
hide_totals?: boolean | string;
|
hide_totals?: boolean | string;
|
||||||
|
@ -63,6 +60,13 @@ export interface Status {
|
||||||
to?: string[];
|
to?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API Responses
|
||||||
|
export interface ApiResponse {
|
||||||
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatusResponse {
|
export interface StatusResponse {
|
||||||
id: string;
|
id: string;
|
||||||
create_at: Date;
|
create_at: Date;
|
||||||
|
@ -78,6 +82,7 @@ export interface Account {
|
||||||
avatar_static: string;
|
avatar_static: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachments
|
||||||
export interface StatusAttachment {
|
export interface StatusAttachment {
|
||||||
file: string;
|
file: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
@ -89,10 +94,10 @@ export interface UploadAttachResponse{
|
||||||
id: string;
|
id: string;
|
||||||
pleroma: {
|
pleroma: {
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
}
|
};
|
||||||
preview_url: string;
|
preview_url: string;
|
||||||
remote_url: string | null;
|
remote_url: string | null;
|
||||||
text_url: string;
|
text_url: string;
|
||||||
type: "image" | "video" | "audio" | "unknown",
|
type: "image" | "video" | "audio" | "unknown";
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
Loading…
Reference in a new issue