refactor: api&oauth

This commit is contained in:
sevichecc 2023-04-16 01:23:23 +08:00
parent 24f904a60f
commit f50b45aaed
Signed by untrusted user who does not match committer: SevicheCC
GPG key ID: C577000000000000
5 changed files with 101 additions and 116 deletions

View file

@ -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,77 +62,43 @@ 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();
const formData = new FormData(); const formData = new FormData();
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 };

View file

@ -4,16 +4,12 @@ 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 (
<SimpleCommand {...props}> <SimpleCommand {...props}>
<Form.FilePicker id="files" value={files} onChange={setFiles}/> <Form.FilePicker id="files" value={files} onChange={setFiles} />
{files.length !== 0 && <Form.TextArea id="description" title="Alt text" />} {files.length !== 0 && <Form.TextArea id="description" title="Alt text" />}
<Form.DatePicker id="datepicker" title="Scheduled Time" /> <Form.DatePicker id="datepicker" title="Scheduled Time" />
<VisibilityDropdown /> <VisibilityDropdown />

View file

@ -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 };

View file

@ -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));

View file

@ -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,21 +82,22 @@ export interface Account {
avatar_static: string; avatar_static: string;
} }
// Attachments
export interface StatusAttachment { export interface StatusAttachment {
file: string; file: string;
description?: string; description?: string;
focus?: { x: number; y: number }; focus?: { x: number; y: number };
} }
export interface UploadAttachResponse{ export interface UploadAttachResponse {
description: string | null; description: string | null;
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;
} }