feat(web): now supports rust backend api server
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
parent
284321a240
commit
f450a1f735
@ -1,10 +0,0 @@
|
||||
version: "3.3"
|
||||
|
||||
services:
|
||||
api:
|
||||
container_name: dust-mail-api
|
||||
image: guusvanmeerveld/dust-mail-server
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./config/config.toml:/home/dust-mail/.config/dust-mail-server/config.toml
|
@ -0,0 +1,29 @@
|
||||
use rocket::{serde::Serialize, State};
|
||||
|
||||
use crate::{
|
||||
state::{AuthType, Config},
|
||||
types::{OkResponse, ResponseResult},
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
|
||||
pub struct SettingsResponse {
|
||||
authorization: bool,
|
||||
authorization_type: Option<AuthType>,
|
||||
}
|
||||
|
||||
#[get("/settings")]
|
||||
pub fn settings(config: &State<Config>) -> ResponseResult<SettingsResponse> {
|
||||
let auth_enabled = config.authorization().is_some();
|
||||
let authorization_type = config
|
||||
.authorization()
|
||||
.map(|auth_config| auth_config.auth_type().clone());
|
||||
|
||||
let settings_response = SettingsResponse {
|
||||
authorization: auth_enabled,
|
||||
authorization_type,
|
||||
};
|
||||
|
||||
Ok(OkResponse::new(settings_response))
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
use rocket::serde::Serialize;
|
||||
|
||||
use crate::types::{OkResponse, ResponseResult};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct VersionResponse {
|
||||
version: String,
|
||||
r#type: String,
|
||||
}
|
||||
|
||||
#[get("/version")]
|
||||
pub fn version() -> ResponseResult<VersionResponse> {
|
||||
let response = VersionResponse {
|
||||
version: "0.2.4".into(),
|
||||
r#type: "git".into(),
|
||||
};
|
||||
|
||||
Ok(OkResponse::new(response))
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
import z from "zod";
|
||||
|
||||
import { Result } from "./result";
|
||||
|
||||
import { ApiSettingsModel } from "@models/api/settings";
|
||||
|
||||
export default interface ApiClient {
|
||||
getChangelog: () => Promise<Result<string>>;
|
||||
getSettings: (
|
||||
baseUrl?: string
|
||||
) => Promise<Result<z.infer<typeof ApiSettingsModel>>>;
|
||||
login: (
|
||||
baseUrl?: string,
|
||||
password?: string,
|
||||
username?: string
|
||||
) => Promise<Result<void>>;
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
import {
|
||||
LoginResponse,
|
||||
PublicTokensResponse,
|
||||
MessageCountResponse
|
||||
} from "@dust-mail/typings";
|
||||
|
||||
export default interface HttpClient {
|
||||
refresh: (refreshToken: string) => Promise<LoginResponse>;
|
||||
getPublicOAuthTokens: () => Promise<PublicTokensResponse>;
|
||||
getChangelog: () => Promise<string>;
|
||||
getAvatar: (address: string | null) => Promise<string | undefined>;
|
||||
createBox: (id: string) => Promise<void>;
|
||||
deleteBox: (ids: string[]) => Promise<void>;
|
||||
renameBox: (oldBoxID: string, newBoxID: string) => Promise<void>;
|
||||
getMessageCount: (
|
||||
boxes: string[],
|
||||
flag: string,
|
||||
token?: string
|
||||
) => Promise<MessageCountResponse>;
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { Error as ErrorModel } from "@models/error";
|
||||
|
||||
export interface Error {
|
@ -0,0 +1,19 @@
|
||||
import z from "zod";
|
||||
|
||||
import { Error as ErrorModel } from "../error";
|
||||
|
||||
export const OkResponseModel = z.object({
|
||||
ok: z.literal(true),
|
||||
data: z.unknown()
|
||||
});
|
||||
|
||||
export const ErrorResponseModel = z.object({
|
||||
ok: z.literal(false),
|
||||
error: ErrorModel
|
||||
});
|
||||
|
||||
export const ResponseModel = z.union([ErrorResponseModel, OkResponseModel]);
|
||||
|
||||
export type Response =
|
||||
| z.infer<typeof OkResponseModel>
|
||||
| z.infer<typeof ErrorModel>;
|
@ -0,0 +1,6 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ApiSettingsModel = z.object({
|
||||
authorization: z.boolean(),
|
||||
authorization_type: z.enum(["password", "user"])
|
||||
});
|
@ -0,0 +1,16 @@
|
||||
import createMd5Hash from "js-md5";
|
||||
|
||||
const createAvatarUrl = (email: string): string => {
|
||||
const hashed = createMd5Hash(email.trim().toLowerCase());
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set("s", "64");
|
||||
searchParams.set("d", "404");
|
||||
|
||||
return (
|
||||
"https://www.gravatar.com/avatar/" + hashed + "?" + searchParams.toString()
|
||||
);
|
||||
};
|
||||
|
||||
export default createAvatarUrl;
|
@ -0,0 +1,87 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { version } from "../../../package.json";
|
||||
import useFetchClient from "./useFetchClient";
|
||||
|
||||
import { ApiSettingsModel } from "@models/api/settings";
|
||||
|
||||
import ApiClient from "@interfaces/api";
|
||||
|
||||
import { createBaseError, createResultFromUnknown } from "@utils/parseError";
|
||||
import parseZodOutput from "@utils/parseZodOutput";
|
||||
|
||||
const useApiClient = (): ApiClient => {
|
||||
const fetch = useFetchClient();
|
||||
|
||||
return {
|
||||
async getChangelog() {
|
||||
const response = await window
|
||||
.fetch(
|
||||
`https://raw.githubusercontent.com/${
|
||||
import.meta.env.VITE_REPO
|
||||
}/${version}/CHANGELOG.md`,
|
||||
{ method: "GET" }
|
||||
)
|
||||
.then((response) => response.text())
|
||||
.then((data) => ({ ok: true as const, data }))
|
||||
.catch((error) => {
|
||||
return createBaseError({
|
||||
kind: "GithubError",
|
||||
message: JSON.stringify(error)
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
async getSettings(baseUrl?: string) {
|
||||
return await fetch("/settings", {
|
||||
baseUrl,
|
||||
method: "GET",
|
||||
sendAuth: false,
|
||||
useMailSessionToken: false
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const output = ApiSettingsModel.safeParse(response.data);
|
||||
|
||||
return parseZodOutput(output);
|
||||
})
|
||||
.catch(createResultFromUnknown);
|
||||
},
|
||||
async login(baseUrl, password, username) {
|
||||
const formData = new FormData();
|
||||
|
||||
if (password !== undefined) formData.append("password", password);
|
||||
if (username !== undefined) formData.append("username", username);
|
||||
|
||||
return await fetch("/login", {
|
||||
method: "POST",
|
||||
contentType: "none",
|
||||
baseUrl,
|
||||
useMailSessionToken: false,
|
||||
body: formData
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const output = z.string().safeParse(response.data);
|
||||
|
||||
const parsedOutput = parseZodOutput(output);
|
||||
|
||||
if (!parsedOutput.ok) {
|
||||
return parsedOutput;
|
||||
}
|
||||
|
||||
return { ...parsedOutput, data: undefined };
|
||||
})
|
||||
.catch(createResultFromUnknown);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default useApiClient;
|
@ -1,65 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ErrorResponse } from "@dust-mail/typings";
|
||||
|
||||
// import { AxiosError } from "axios";
|
||||
import useFetch from "@utils/hooks/useFetch";
|
||||
|
||||
/**
|
||||
* Time to refresh avatars in inbox in seconds
|
||||
*/
|
||||
const REFRESH_INBOX_AVATARS = 60 * 60;
|
||||
|
||||
export default function useAvatar(
|
||||
email: string | null
|
||||
): { data?: string; isLoading: boolean } | void {
|
||||
const fetcher = useFetch();
|
||||
|
||||
const id = ["noAvatar", email].join("-");
|
||||
|
||||
let noAvatar: number | undefined;
|
||||
let setNoAvatar: ((date: number) => void) | undefined;
|
||||
|
||||
if (
|
||||
"sessionStorage" in window &&
|
||||
"getItem" in sessionStorage &&
|
||||
"setItem" in sessionStorage
|
||||
) {
|
||||
noAvatar = parseInt(sessionStorage.getItem(id) ?? "");
|
||||
setNoAvatar = (date) => sessionStorage.setItem(id, date.toString());
|
||||
}
|
||||
|
||||
const blacklisted = !!noAvatar;
|
||||
|
||||
const { data, isLoading, error } = useQuery<
|
||||
string | undefined,
|
||||
AxiosError<ErrorResponse>
|
||||
>(["avatar", email], () => fetcher.getAvatar(email), {
|
||||
retry: false,
|
||||
onError: () => {
|
||||
return;
|
||||
},
|
||||
enabled:
|
||||
(email != null && !blacklisted) || !!(noAvatar && noAvatar < Date.now())
|
||||
});
|
||||
|
||||
const avatar = useMemo(() => ({ data, isLoading }), [data, isLoading]);
|
||||
|
||||
if (!email || email == "") return;
|
||||
|
||||
if (
|
||||
error?.response &&
|
||||
error.response.status != 401 &&
|
||||
!data &&
|
||||
!isLoading &&
|
||||
(!noAvatar || noAvatar < Date.now()) &&
|
||||
setNoAvatar
|
||||
) {
|
||||
setNoAvatar(Date.now() + REFRESH_INBOX_AVATARS * 1000);
|
||||
}
|
||||
|
||||
return avatar;
|
||||
}
|
@ -1,96 +0,0 @@
|
||||
import useLocalStorageState from "use-local-storage-state";
|
||||
|
||||
import { version } from "../../../package.json";
|
||||
import useUser from "./useUser";
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import HttpClient from "@interfaces/http";
|
||||
|
||||
const UNIX_PREFIX = "unix://";
|
||||
|
||||
const useHttpClient = (token?: string): HttpClient => {
|
||||
let [backendServer] = useLocalStorageState<string>("customServerUrl", {
|
||||
defaultValue: import.meta.env.VITE_DEFAULT_SERVER
|
||||
});
|
||||
|
||||
const user = useUser();
|
||||
|
||||
const isUnixSocket = backendServer.startsWith(UNIX_PREFIX);
|
||||
|
||||
if (isUnixSocket) {
|
||||
backendServer = backendServer.replace(UNIX_PREFIX, "");
|
||||
}
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: backendServer,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token ?? user?.token}`
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
async getChangelog() {
|
||||
const { data } = await axios.get(
|
||||
`https://raw.githubusercontent.com/${
|
||||
import.meta.env.VITE_REPO
|
||||
}/${version}/CHANGELOG.md`
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
async refresh(refreshToken) {
|
||||
const { data } = await instance.get("/auth/refresh", {
|
||||
headers: { Authorization: `Bearer ${refreshToken}` }
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
async getPublicOAuthTokens() {
|
||||
const { data } = await instance.get("/auth/oauth/tokens");
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
async createBox(id: string) {
|
||||
await instance.put("/mail/folder/create", { id });
|
||||
},
|
||||
async deleteBox(ids: string[]) {
|
||||
await instance.delete("/mail/folder/delete", {
|
||||
params: { id: ids.join(",") }
|
||||
});
|
||||
},
|
||||
async renameBox(oldBoxID: string, newBoxID: string) {
|
||||
await instance.put("/mail/folder/rename", {
|
||||
oldID: oldBoxID,
|
||||
newID: newBoxID
|
||||
});
|
||||
},
|
||||
async getMessageCount(boxes, flag, token?: string) {
|
||||
const { data } = await instance.get("/mail/message/count", {
|
||||
params: {
|
||||
boxes: boxes.join(","),
|
||||
flag
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${token ?? user?.token}`
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
async getAvatar(address) {
|
||||
const res = await instance.get("/avatar", {
|
||||
params: { address }
|
||||
// validateStatus: (status) =>
|
||||
// (status >= 200 && status < 300)
|
||||
});
|
||||
|
||||
return res?.data;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default useHttpClient;
|
@ -0,0 +1,117 @@
|
||||
import useSettings from "./useSettings";
|
||||
import useUser from "./useUser";
|
||||
|
||||
import { ResponseModel as ApiResponseModel } from "@models/api";
|
||||
|
||||
import { Result } from "@interfaces/result";
|
||||
|
||||
import { createBaseError, parseError } from "@utils/parseError";
|
||||
import parseJsonAsync from "@utils/parseJson";
|
||||
import parseZodOutput from "@utils/parseZodOutput";
|
||||
|
||||
type FetchFunction = (
|
||||
url: string,
|
||||
config?: {
|
||||
body?: BodyInit;
|
||||
params?: Record<string, string>;
|
||||
baseUrl?: string;
|
||||
method?: "POST" | "GET" | "DELETE" | "PUT";
|
||||
sendAuth?: boolean;
|
||||
useMailSessionToken?: boolean;
|
||||
contentType?: "json" | "form" | "none";
|
||||
}
|
||||
) => Promise<Result<unknown>>;
|
||||
|
||||
const useFetchClient = (): FetchFunction => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const user = useUser();
|
||||
|
||||
const httpServerUrl = settings.httpServerUrl;
|
||||
|
||||
const fetch: FetchFunction = async (path, config) => {
|
||||
const backendUrl = config?.baseUrl ?? httpServerUrl;
|
||||
|
||||
if (backendUrl == null) {
|
||||
return createBaseError({
|
||||
kind: "NoBackendUrl",
|
||||
message: "Backend url for api server is not set"
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(path, backendUrl);
|
||||
|
||||
if (config?.params !== undefined)
|
||||
Object.entries(config.params).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
if (config?.useMailSessionToken !== false && user?.token !== undefined)
|
||||
url.searchParams.set("session_token", user.token);
|
||||
|
||||
if (typeof window === "undefined" || !("fetch" in window)) {
|
||||
return createBaseError({
|
||||
kind: "FetchUnsupported",
|
||||
message: "The fetch api is not supported in this environment"
|
||||
});
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
|
||||
if (config?.contentType === "form") {
|
||||
headers.append("Content-Type", "application/x-www-form-urlencoded");
|
||||
} else if (config?.contentType !== "none") {
|
||||
headers.append("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return await window
|
||||
.fetch(url.toString(), {
|
||||
body: config?.body,
|
||||
method: config?.method ?? "GET",
|
||||
credentials: config?.sendAuth !== false ? "include" : "omit",
|
||||
referrerPolicy: "no-referrer",
|
||||
headers
|
||||
})
|
||||
.then(async (response) => {
|
||||
const responseString = await response.text().catch(() => null);
|
||||
|
||||
if (responseString === null)
|
||||
return createBaseError({
|
||||
kind: "InvalidResponseBody",
|
||||
message: "Invalid response body from server response"
|
||||
});
|
||||
|
||||
return await parseJsonAsync(responseString)
|
||||
.then((parsedOutput) => {
|
||||
const parseOutputResult = ApiResponseModel.safeParse(parsedOutput);
|
||||
|
||||
const parsedZodOutput = parseZodOutput(parseOutputResult);
|
||||
|
||||
if (parsedZodOutput.ok) {
|
||||
const serverResponseResult = parsedZodOutput.data;
|
||||
|
||||
// We have to do this ugly little workaround because the zod unknown parser returns an optional unknown in its ts type.
|
||||
// See https://github.com/colinhacks/zod/issues/493
|
||||
if (serverResponseResult.ok) {
|
||||
return {
|
||||
...serverResponseResult,
|
||||
data: serverResponseResult.data as unknown
|
||||
};
|
||||
} else {
|
||||
return serverResponseResult;
|
||||
}
|
||||
} else {
|
||||
return parsedZodOutput;
|
||||
}
|
||||
})
|
||||
.catch((error: string) =>
|
||||
createBaseError({ kind: "ParseJSON", message: error })
|
||||
);
|
||||
})
|
||||
.catch(parseError);
|
||||
};
|
||||
|
||||
return fetch;
|
||||
};
|
||||
|
||||
export default useFetchClient;
|