Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"lodash": "^4.17.21",
"marked": "^16.0.0",
"pinia": "^3.0.2",
"uuid": "^11.1.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
Expand Down
75 changes: 66 additions & 9 deletions src/stores/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { HttpError } from '@api/http-error.ts';
import { v4 as uuidv4 } from 'uuid';

const ENV_PROD = 'production';

export const defaultCreds: ApiClientOptions = {
env: import.meta.env.VITE_API_ENV as string,
apiKey: import.meta.env.VITE_PUBLIC_KEY as string,
apiUsername: import.meta.env.VITE_ACCOUNT_NAME as string,
apiSignature: import.meta.env.VITE_PUBLIC_SIGNATURE as string,
};

export interface ApiClientOptions {
env: string;
apiKey: string;
apiUsername: string;
apiSignature: string;
}

export interface CacheEntry<T> {
Expand All @@ -31,14 +30,18 @@ export class ApiClient {
private readonly apiKey: string;
private readonly basedURL: string;
private readonly apiUsername: string;
private readonly apiSignature: string;
private readonly timestamp: string;

constructor(options: ApiClientOptions) {
this.env = options.env;
this.apiKey = options.apiKey;
this.apiUsername = options.apiUsername;
this.apiSignature = options.apiSignature;
this.basedURL = `${import.meta.env.VITE_API_URL}`;
this.timestamp = Math.floor(Date.now() / 1000).toString();
}

public createNonce(): string {
return crypto.getRandomValues(new Uint8Array(16)).reduce((s, b) => s + b.toString(16).padStart(2, '0'), '');
}

private getCacheKey(url: string): string {
Expand Down Expand Up @@ -71,18 +74,68 @@ export class ApiClient {
const headers = new Headers();

headers.append('X-API-Key', this.apiKey);
headers.append('X-Request-ID', uuidv4());
headers.append('User-Agent', 'oullin/web-app');
headers.append('X-API-Timestamp', this.timestamp);
headers.append('X-API-Username', this.apiUsername);
headers.append('X-API-Signature', this.apiSignature);

headers.append('Content-Type', 'application/json');

return headers;
}

public async post<T>(url: string, data: object): Promise<T> {
private async sha256Hex(text: string): Promise<string> {
const enc = new TextEncoder();

const buf = await crypto.subtle.digest('SHA-256', enc.encode(text));

return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

private async hmacSha256Hex(secret: string, message: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{
name: 'HMAC',
hash: 'SHA-256',
},
false,
['sign'],
);

const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message));

return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

private getSortedQuery(u: string): string {
const params = new URL(u).searchParams;

params.sort();
return params.toString();
}

private async getSignature(method: string, uri: URL, body: string, nonce: string): Promise<string> {
const content = await this.sha256Hex(body);

const canonical = [method.toUpperCase(), uri.pathname || '/', this.getSortedQuery(uri.href), this.apiUsername, this.apiKey, this.timestamp, nonce, content].join('\n');

return await this.hmacSha256Hex(this.apiKey, canonical);
}

public async post<T>(url: string, nonce: string, data: object): Promise<T> {
const headers = this.createHeaders();
const fullUrl = new URL(url, this.basedURL);
const content: string = JSON.stringify(data);
const signature = await this.getSignature('POST', fullUrl, content, nonce);

headers.append('X-API-Nonce', nonce);
headers.append('X-API-Signature', signature);

const response = await fetch(fullUrl.href, {
method: 'POST',
Expand All @@ -97,15 +150,19 @@ export class ApiClient {
return await response.json();
}

public async get<T>(url: string): Promise<T> {
public async get<T>(url: string, nonce: string): Promise<T> {
const headers = this.createHeaders();
const fullUrl = new URL(url, this.basedURL);
const cached = this.getFromCache<T>(url);
const fullUrl = new URL(url, this.basedURL);

if (cached) {
headers.append('If-None-Match', cached.etag);
}

const signature = await this.getSignature('GET', fullUrl, '', nonce);
headers.append('X-API-Signature', signature);
headers.append('X-API-Nonce', nonce);

const response = await fetch(fullUrl.href, {
method: 'GET',
headers: headers,
Expand Down
30 changes: 20 additions & 10 deletions src/stores/api/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,90 +41,100 @@
},
async getProfile(): Promise<ApiResponse<ProfileResponse>> {
const url = 'profile';
const nonce = this.client.createNonce();

Check failure on line 44 in src/stores/api/store.ts

View workflow job for this annotation

GitHub Actions / vitest

tests/stores/api/store.test.ts > useApiStore > gets profile

TypeError: this.client.createNonce is not a function ❯ Proxy.getProfile src/stores/api/store.ts:44:30 ❯ Proxy.wrappedAction node_modules/pinia/dist/pinia.mjs:1394:26 ❯ tests/stores/api/store.test.ts:45:27

try {
return await this.client.get<ApiResponse<ProfileResponse>>(url);
return await this.client.get<ApiResponse<ProfileResponse>>(url, nonce);
} catch (error) {
return parseError(error);
}
},
async getExperience(): Promise<ApiResponse<ExperienceResponse[]>> {
const url = 'experience';
const nonce = this.client.createNonce();

Check failure on line 54 in src/stores/api/store.ts

View workflow job for this annotation

GitHub Actions / vitest

tests/stores/api/store.test.ts > useApiStore > gets experience

TypeError: this.client.createNonce is not a function ❯ Proxy.getExperience src/stores/api/store.ts:54:30 ❯ Proxy.wrappedAction node_modules/pinia/dist/pinia.mjs:1394:26 ❯ tests/stores/api/store.test.ts:89:27

try {
return await this.client.get<ApiResponse<ExperienceResponse[]>>(url);
return await this.client.get<ApiResponse<ExperienceResponse[]>>(url, nonce);
} catch (error) {
return parseError(error);
}
},
async getRecommendations(): Promise<ApiResponse<RecommendationsResponse[]>> {
const url = 'recommendations';
const nonce = this.client.createNonce();

try {
return await this.client.get<ApiResponse<RecommendationsResponse[]>>(url);
return await this.client.get<ApiResponse<RecommendationsResponse[]>>(url, nonce);
} catch (error) {
return parseError(error);
}
},
async getProjects(): Promise<ApiResponse<ProjectsResponse[]>> {
const url = 'projects';
const nonce = this.client.createNonce();

try {
return await this.client.get<ApiResponse<ProjectsResponse[]>>(url);
return await this.client.get<ApiResponse<ProjectsResponse[]>>(url, nonce);
} catch (error) {
return parseError(error);
}
},
async getTalks(): Promise<ApiResponse<TalksResponse[]>> {
const url = 'talks';
const nonce = this.client.createNonce();

try {
return await this.client.get<ApiResponse<TalksResponse[]>>(url);
return await this.client.get<ApiResponse<TalksResponse[]>>(url, nonce);
} catch (error) {
return parseError(error);
}
},
async getSocial(): Promise<ApiResponse<SocialResponse[]>> {
const url = 'social';
const nonce = this.client.createNonce();

try {
return await this.client.get<ApiResponse<SocialResponse[]>>(url);
return await this.client.get<ApiResponse<SocialResponse[]>>(url, nonce);
} catch (error) {
return parseError(error);
}
},
async getEducation(): Promise<ApiResponse<EducationResponse[]>> {
const url = 'education';
const nonce = this.client.createNonce();

try {
return await this.client.get<ApiResponse<EducationResponse[]>>(url);
return await this.client.get<ApiResponse<EducationResponse[]>>(url, nonce);
} catch (error) {
return parseError(error);
}
},
async getCategories(): Promise<CategoriesCollectionResponse> {
const url = 'categories?limit=5';
const nonce = this.client.createNonce();

Check failure on line 114 in src/stores/api/store.ts

View workflow job for this annotation

GitHub Actions / vitest

tests/stores/api/store.test.ts > useApiStore > gets categories

TypeError: this.client.createNonce is not a function ❯ Proxy.getCategories src/stores/api/store.ts:114:30 ❯ Proxy.wrappedAction node_modules/pinia/dist/pinia.mjs:1394:26 ❯ tests/stores/api/store.test.ts:56:27

try {
return await this.client.get<CategoriesCollectionResponse>(url);
return await this.client.get<CategoriesCollectionResponse>(url, nonce);
} catch (error) {
return parseError(error);
}
},
async getPosts(filters: PostsFilters): Promise<PostsCollectionResponse> {
const url = 'posts?limit=5';
const nonce = this.client.createNonce();

Check failure on line 124 in src/stores/api/store.ts

View workflow job for this annotation

GitHub Actions / vitest

tests/stores/api/store.test.ts > useApiStore > gets posts

TypeError: this.client.createNonce is not a function ❯ Proxy.getPosts src/stores/api/store.ts:124:30 ❯ Proxy.wrappedAction node_modules/pinia/dist/pinia.mjs:1394:26 ❯ tests/stores/api/store.test.ts:67:27

try {
return await this.client.post<PostsCollectionResponse>(url, filters);
return await this.client.post<PostsCollectionResponse>(url, nonce, filters);
} catch (error) {
return parseError(error);
}
},
async getPost(slug: string): Promise<PostResponse> {
const url = `posts/${slug}`;
const nonce = this.client.createNonce();

Check failure on line 134 in src/stores/api/store.ts

View workflow job for this annotation

GitHub Actions / vitest

tests/stores/api/store.test.ts > useApiStore > gets single post

TypeError: this.client.createNonce is not a function ❯ Proxy.getPost src/stores/api/store.ts:134:30 ❯ Proxy.wrappedAction node_modules/pinia/dist/pinia.mjs:1394:26 ❯ tests/stores/api/store.test.ts:78:27

try {
return await this.client.get<PostResponse>(url);
return await this.client.get<PostResponse>(url, nonce);
} catch (error) {
return parseError(error);
}
Expand Down
1 change: 0 additions & 1 deletion tests/stores/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ const options: ApiClientOptions = {
env: 'development',
apiKey: 'k',
apiUsername: 'u',
apiSignature: 's',
};

const url = 'http://example.com/';
Expand Down
Loading