diff --git a/package-lock.json b/package-lock.json index 7484417..359c212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tanstack/query-sync-storage-persister": "^4.36.1", "@tanstack/svelte-query": "^4.36.1", "datetrigger": "^1.1.1", + "moment": "^2.30.1", "svelte-material-icons": "^3.0.5" }, "devDependencies": { @@ -3429,6 +3430,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", diff --git a/package.json b/package.json index 864efac..fd3ec23 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@tanstack/query-sync-storage-persister": "^4.36.1", "@tanstack/svelte-query": "^4.36.1", "datetrigger": "^1.1.1", + "moment": "^2.30.1", "svelte-material-icons": "^3.0.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a1c0b7..942a5f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: datetrigger: specifier: ^1.1.1 version: 1.1.1 + moment: + specifier: ^2.30.1 + version: 2.30.1 svelte-material-icons: specifier: ^3.0.5 version: 3.0.5(svelte@4.2.18) @@ -1192,6 +1195,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1241,8 +1247,8 @@ packages: periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} - picocolors@1.0.1: - resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + picocolors@1.1.0: + resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} @@ -1276,8 +1282,8 @@ packages: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} - postcss@8.4.41: - resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==} + postcss@8.4.47: + resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -1343,8 +1349,8 @@ packages: resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==} engines: {node: '>=6'} - semver@7.6.2: - resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} hasBin: true @@ -1375,8 +1381,8 @@ packages: resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==} engines: {node: '>=18'} - source-map-js@1.2.0: - resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} sprintf-js@1.0.3: @@ -2051,7 +2057,7 @@ snapshots: fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.6.2 + semver: 7.6.3 ts-api-utils: 1.3.0(typescript@5.7.2) optionalDependencies: typescript: 5.7.2 @@ -2175,7 +2181,7 @@ snapshots: css-tree@2.3.1: dependencies: mdn-data: 2.0.30 - source-map-js: 1.2.0 + source-map-js: 1.2.1 cssesc@3.0.0: {} @@ -2229,7 +2235,7 @@ snapshots: eslint-compat-utils@0.5.1(eslint@9.16.0): dependencies: eslint: 9.16.0 - semver: 7.6.2 + semver: 7.6.3 eslint-config-prettier@9.1.0(eslint@9.16.0): dependencies: @@ -2243,11 +2249,11 @@ snapshots: eslint-compat-utils: 0.5.1(eslint@9.16.0) esutils: 2.0.3 known-css-properties: 0.35.0 - postcss: 8.4.41 - postcss-load-config: 3.1.4(postcss@8.4.41) - postcss-safe-parser: 6.0.0(postcss@8.4.41) + postcss: 8.4.47 + postcss-load-config: 3.1.4(postcss@8.4.47) + postcss-safe-parser: 6.0.0(postcss@8.4.47) postcss-selector-parser: 6.1.2 - semver: 7.6.2 + semver: 7.6.3 svelte-eslint-parser: 0.43.0(svelte@4.2.18) optionalDependencies: svelte: 4.2.18 @@ -2500,6 +2506,8 @@ snapshots: minimist@1.2.8: {} + moment@2.30.1: {} + mri@1.2.0: {} mrmime@2.0.0: {} @@ -2544,35 +2552,35 @@ snapshots: estree-walker: 3.0.3 is-reference: 3.0.2 - picocolors@1.0.1: {} + picocolors@1.1.0: {} picomatch@2.3.1: {} - postcss-load-config@3.1.4(postcss@8.4.41): + postcss-load-config@3.1.4(postcss@8.4.47): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: - postcss: 8.4.41 + postcss: 8.4.47 - postcss-safe-parser@6.0.0(postcss@8.4.41): + postcss-safe-parser@6.0.0(postcss@8.4.47): dependencies: - postcss: 8.4.41 + postcss: 8.4.47 - postcss-scss@4.0.9(postcss@8.4.41): + postcss-scss@4.0.9(postcss@8.4.47): dependencies: - postcss: 8.4.41 + postcss: 8.4.47 postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.4.41: + postcss@8.4.47: dependencies: nanoid: 3.3.7 - picocolors: 1.0.1 - source-map-js: 1.2.0 + picocolors: 1.1.0 + source-map-js: 1.2.1 prelude-ls@1.2.1: {} @@ -2629,7 +2637,7 @@ snapshots: dependencies: chokidar: 4.0.3 immutable: 5.0.3 - source-map-js: 1.2.0 + source-map-js: 1.2.1 optionalDependencies: '@parcel/watcher': 2.5.0 @@ -2639,7 +2647,7 @@ snapshots: semiver@1.1.0: {} - semver@7.6.2: {} + semver@7.6.3: {} set-cookie-parser@2.7.0: {} @@ -2647,7 +2655,7 @@ snapshots: dependencies: color: 4.2.3 detect-libc: 2.0.3 - semver: 7.6.2 + semver: 7.6.3 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.4 '@img/sharp-darwin-x64': 0.33.4 @@ -2696,7 +2704,7 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 - source-map-js@1.2.0: {} + source-map-js@1.2.1: {} sprintf-js@1.0.3: {} @@ -2711,7 +2719,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 chokidar: 4.0.3 fdir: 6.4.2 - picocolors: 1.0.1 + picocolors: 1.1.0 sade: 1.8.1 svelte: 4.2.18 typescript: 5.7.2 @@ -2723,8 +2731,8 @@ snapshots: eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 espree: 9.6.1 - postcss: 8.4.41 - postcss-scss: 4.0.9(postcss@8.4.41) + postcss: 8.4.47 + postcss-scss: 4.0.9(postcss@8.4.47) optionalDependencies: svelte: 4.2.18 @@ -2813,7 +2821,7 @@ snapshots: vite@5.3.3(sass@1.81.0): dependencies: esbuild: 0.21.5 - postcss: 8.4.41 + postcss: 8.4.47 rollup: 4.20.0 optionalDependencies: fsevents: 2.3.3 diff --git a/src/app.scss b/src/app.scss index abc8a45..c7a850d 100644 --- a/src/app.scss +++ b/src/app.scss @@ -74,6 +74,7 @@ body { --red-one: hsl(333, 84%, 62%); --red-two: hsl(357, 74%, 60%); + --red-three: hsl(2, 68%, 83%); --yellow-one: hsl(59, 100%, 72%); @@ -86,6 +87,10 @@ body { background-color: var(--tertiary); } +mark { + background-color: var(--secondary); +} + /*-----headings-----*/ h1 { @@ -173,7 +178,13 @@ hr { border-top: 1px solid var(--border); } -input { +textarea { + resize: vertical; + field-sizing: content; +} + +input, +textarea { padding: 1rem; border-radius: 12px; border: 1px solid var(--border); @@ -181,6 +192,7 @@ input { color: var(--secondary); } -input:focus { +input:focus, +textarea:focus { outline: 1px solid var(--primary); } diff --git a/src/data/api/index.ts b/src/data/api/index.ts index a5771fe..d9b2211 100644 --- a/src/data/api/index.ts +++ b/src/data/api/index.ts @@ -9,8 +9,12 @@ import type { DonationPlatform, CryptoWallet, Social, - About + About, + ResponseAnnouncement, + Announcement, + Tags } from '$lib/types'; +import { get_access_token, is_logged_in, UnauthenticatedError } from '$lib/auth'; export type ContributorsData = { contributables: Contributable[] }; export type PatchesData = { patches: Patch[]; packages: string[] }; @@ -19,10 +23,70 @@ export type TeamData = { members: TeamMember[] }; export type AboutData = { about: About }; export type DonationData = { wallets: CryptoWallet[]; platforms: DonationPlatform[] }; export type SocialsData = { socials: Social[] }; +export type AnnouncementsData = { announcements: ResponseAnnouncement[] }; + +type GetAnnouncementsOptions = Partial<{ + tags: string[]; + count: number; + cursor: number; +}>; + +export function build_url(endpoint: string) { + // //////v4/contributors -> v4/contributors + endpoint = endpoint.replace(/^\/+/, ''); + + // v4/contributors -> contributors + if (endpoint.startsWith(settings.API_VERSION)) endpoint = endpoint.split('/').slice(1).join('/'); + + return `${settings.api_base_url()}/${settings.API_VERSION}/${endpoint}`; +} + +function build_headers() { + const access_token_data = get_access_token(); + return { + 'Content-Type': 'application/json', + Authorization: access_token_data ? `Bearer ${access_token_data.token}` : '' + }; +} async function get_json(endpoint: string) { - const url = `${settings.api_base_url()}/${endpoint}`; - return await fetch(url).then((r) => r.json()); + return await fetch(build_url(endpoint)).then((r) => r.json()); +} + +async function post_json(endpoint: string, body?: any) { + if (!is_logged_in()) throw new UnauthenticatedError(); + const headers = build_headers(); + return await fetch(build_url(endpoint), { + method: 'POST', + headers, + body: body ? JSON.stringify(body) : '' + }).then((r) => { + return r.headers.get('content-length') === '0' ? null : r.json(); + }); +} + +async function patch_json(endpoint: string, body?: any) { + if (!is_logged_in()) throw new UnauthenticatedError(); + const headers = build_headers(); + return await fetch(build_url(endpoint), { + method: 'PATCH', + headers, + body: body ? JSON.stringify(body) : '' + }).then((r) => { + return r.headers.get('content-length') === '0' ? null : r.json(); + }); +} + +async function delete_json(endpoint: string, body?: any) { + if (!is_logged_in()) throw new UnauthenticatedError(); + const headers = build_headers(); + return await fetch(build_url(endpoint), { + method: 'DELETE', + headers, + body: body ? JSON.stringify(body) : '' + }).then((r) => { + return r.headers.get('content-length') === '0' ? null : r.json(); + }); } async function contributors(): Promise { @@ -74,6 +138,58 @@ async function about(): Promise { return { about: json }; } +async function announcements(options: GetAnnouncementsOptions = {}): Promise { + const url = new URL(build_url('announcements')); + + if (options.tags && options.tags.length > 0) url.searchParams.set('tags', options.tags.join(',')); + if (options.count) url.searchParams.set('count', String(options.count)); + if (options.cursor) url.searchParams.set('cursor', String(options.cursor)); + + const announcements = (await get_json('announcements')) as ResponseAnnouncement[]; + + return { announcements }; +} + +async function get_announcement_by_id(id: number): Promise<{ announcement: ResponseAnnouncement }> { + return { announcement: (await get_json(`announcements/${id}`)) as ResponseAnnouncement }; +} + +async function announcementTags(): Promise<{ tags: Tags }> { + return { tags: (await get_json(`announcements/tags`)) as Tags }; +} + +function show_error_alert(res: Response) { + alert(`A ${res.status < 500 ? 'user' : 'server'} error occurred. Please try again.`); +} + +export async function create_announcement(announcement: Announcement) { + await post_json('announcements', announcement).catch(show_error_alert); +} + +export async function update_announcement(id: number, announcement: Announcement) { + await patch_json(`announcements/${id}`, announcement).catch(show_error_alert); +} + +export async function delete_announcement(id: number) { + await delete_json(`announcements/${id}`).catch(show_error_alert); +} + +export async function archive_announcement(id: number) { + await post_json(`announcements/${id}/archive`).catch(show_error_alert); +} + +export async function unarchive_announcement(id: number) { + await post_json(`announcements/${id}/unarchive`).catch(show_error_alert); +} + +export const admin = { + create_announcement, + update_announcement, + delete_announcement, + archive_announcement, + unarchive_announcement +}; + async function ping(): Promise { try { const res = await fetch(`${settings.api_base_url()}/v4/ping`, { method: 'HEAD' }); @@ -85,34 +201,48 @@ async function ping(): Promise { export const staleTime = 5 * 60 * 1000; export const queries = { - manager: { + manager: () => ({ queryKey: ['manager'], queryFn: manager, staleTime - }, - patches: { + }), + patches: () => ({ queryKey: ['patches'], queryFn: patches, staleTime - }, - contributors: { + }), + contributors: () => ({ queryKey: ['contributors'], queryFn: contributors, staleTime - }, - team: { + }), + team: () => ({ queryKey: ['team'], queryFn: team, staleTime - }, - about: { + }), + about: () => ({ queryKey: ['info'], queryFn: about, staleTime - }, - ping: { + }), + announcements: () => ({ + queryKey: ['announcements'], + queryFn: () => announcements(), + staleTime + }), + announcementById: (id: number) => ({ + queryKey: ['announcementById', id], + queryFn: () => get_announcement_by_id(id) + }), + announcementTags: () => ({ + queryKey: ['announcementTags'], + queryFn: () => announcementTags(), + staleTime + }), + ping: () => ({ queryKey: ['ping'], queryFn: ping, staleTime - } + }) }; diff --git a/src/data/api/settings.ts b/src/data/api/settings.ts index 0b81e7f..01dd93d 100644 --- a/src/data/api/settings.ts +++ b/src/data/api/settings.ts @@ -17,6 +17,8 @@ function set_status_url(apiUrl: string) { }); } +export const API_VERSION = 'v4'; + // Get base URL export function api_base_url(): string { if (browser) { diff --git a/src/layout/Footer/FooterHost.svelte b/src/layout/Footer/FooterHost.svelte index 3551ef6..46efef7 100644 --- a/src/layout/Footer/FooterHost.svelte +++ b/src/layout/Footer/FooterHost.svelte @@ -7,10 +7,10 @@ import Query from '$lib/components/Query.svelte'; import FooterSection from './FooterSection.svelte'; - import { RV_DMCA_GUID } from '$env/static/public'; import { onMount } from 'svelte'; - const aboutQuery = createQuery(['about'], queries.about); + + const aboutQuery = createQuery(queries.about()); let location: string; diff --git a/src/layout/Hero/HeroSection.svelte b/src/layout/Hero/HeroSection.svelte index 3133b8e..69ebb12 100644 --- a/src/layout/Hero/HeroSection.svelte +++ b/src/layout/Hero/HeroSection.svelte @@ -7,7 +7,7 @@ import Button from '$lib/components/Button.svelte'; import SocialButton from './SocialButton.svelte'; - const aboutQuery = createQuery(['about'], queries.about); + const aboutQuery = createQuery(queries.about()); export let socialsVisibility = true; diff --git a/src/layout/Navbar/Modals/LoginModal.svelte b/src/layout/Navbar/Modals/LoginModal.svelte new file mode 100644 index 0000000..70f2119 --- /dev/null +++ b/src/layout/Navbar/Modals/LoginModal.svelte @@ -0,0 +1,86 @@ + + + +
+

Login

+

This login is reserved for site administrators. Go back!

+ {#if wrong_credentials} +

Username or password do not match. Try again.

+ {/if} +
+
+ + event.key === 'Enter' && loginForm.requestSubmit()} + required + /> +
+
+
+ + + + + +
+ + diff --git a/src/layout/Navbar/Modals/LoginSuccessfulModal.svelte b/src/layout/Navbar/Modals/LoginSuccessfulModal.svelte new file mode 100644 index 0000000..0df09b1 --- /dev/null +++ b/src/layout/Navbar/Modals/LoginSuccessfulModal.svelte @@ -0,0 +1,28 @@ + + + + Successfully logged in! + + + + + + + diff --git a/src/layout/Navbar/Modals/SessionExpiredModal.svelte b/src/layout/Navbar/Modals/SessionExpiredModal.svelte new file mode 100644 index 0000000..d553d01 --- /dev/null +++ b/src/layout/Navbar/Modals/SessionExpiredModal.svelte @@ -0,0 +1,35 @@ + + + + Expired session +
+ This session has expired, log in again to renew or lose all access to administrative power. +
+ + + + +
+ + diff --git a/src/layout/Navbar/Modals/SettingsModal.svelte b/src/layout/Navbar/Modals/SettingsModal.svelte new file mode 100644 index 0000000..eda7800 --- /dev/null +++ b/src/layout/Navbar/Modals/SettingsModal.svelte @@ -0,0 +1,116 @@ + + + + + + + Settings +
+

Configure the API for this website.

+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+ + diff --git a/src/layout/Navbar/NavButton.svelte b/src/layout/Navbar/NavButton.svelte index 7138bb2..5144649 100644 --- a/src/layout/Navbar/NavButton.svelte +++ b/src/layout/Navbar/NavButton.svelte @@ -14,12 +14,12 @@ if (queryKey !== null) { if (Array.isArray(queryKey)) { queryKey.forEach((key) => { - const query = queries[key]; + const query = (queries[key] as Function)(); dev_log('Prefetching', query); client.prefetchQuery(query as any); }); } else { - const query = queries[queryKey]; + const query = (queries[queryKey] as Function)(); dev_log('Prefetching', query); client.prefetchQuery(query as any); } @@ -27,20 +27,40 @@ } -
  • +
  • -
  • - diff --git a/src/layout/Navbar/StatusBanner.svelte b/src/layout/Navbar/StatusBanner.svelte new file mode 100644 index 0000000..ccc90e7 --- /dev/null +++ b/src/layout/Navbar/StatusBanner.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..1dcaa65 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,134 @@ +import { browser } from '$app/environment'; +import { build_url } from '$data/api'; + +export type AuthToken = { + token: string; + expires: number; +}; + +type JwtPayload = { + exp: number; + iss: string; + iat: number; +}; + +export class UnauthenticatedError extends Error { + constructor() { + super('Unauthenticated. Cannot perform admin operations.'); + } +} + +// Get access token. +export function get_access_token(): AuthToken | null { + if (!browser) return null; + const data = localStorage.getItem('revanced_api_access_token'); + if (data) return JSON.parse(data) as AuthToken; + return null; +} + +// (Re)set access token. +export function set_access_token(token?: AuthToken) { + if (!token) localStorage.removeItem('revanced_api_access_token'); + else localStorage.setItem('revanced_api_access_token', JSON.stringify(token)); +} + +// Parse a JWT token +export function parseJwt(token: string): JwtPayload { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ); + return JSON.parse(jsonPayload) as JwtPayload; +} + +// Check if the admin is authenticated +export function is_logged_in(): boolean { + const token = get_access_token(); + if (!token) return false; + return Date.now() < token.expires; +} + +async function digest_fetch( + url: string, + username: string, + password: string, + options: RequestInit = {} +): Promise { + // Helper function to convert ArrayBuffer to Hex string + function bufferToHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + } + + // Generate SHA-256 digest + async function sha256(message: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return bufferToHex(hashBuffer); + } + + // Perform an initial request to get the `WWW-Authenticate` header + const initialResponse = await fetch(url, { + method: options.method || 'GET', + headers: options.headers || {} + }); + + if (!initialResponse.ok && initialResponse.status !== 401) + throw new Error(`Initial request failed with status: ${initialResponse.status}`); + + if (initialResponse.ok && initialResponse.status === 200) return initialResponse; + + const authHeader = initialResponse.headers.get('Www-Authenticate'); + if (!authHeader || !authHeader.startsWith('Digest ')) + throw new Error('No Digest authentication header found'); + + // Parse the `WWW-Authenticate` header to extract the fields + const authParams = authHeader + .replace('Digest ', '') + .split(',') + .reduce((acc: Record, item) => { + const [key, value] = item.trim().split('='); + acc[key] = value.replace(/"/g, ''); + return acc; + }, {}); + + const { realm, nonce, algorithm } = authParams; + const method = options.method || 'GET'; + const uri = new URL(url).pathname; + + // https://ktor.io/docs/server-digest-auth.html#flow + const HA1 = await sha256(`${username}:${realm}:${password}`); + const HA2 = await sha256(`${method}:${uri}`); + + const responseHash = await sha256(`${HA1}:${nonce}:${HA2}`); + + // Build the Authorization header + const authHeaderDigest = `Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", algorithm=${algorithm}, response="${responseHash}"`; + + // Perform the final request with the Authorization header + const finalResponse = await fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: authHeaderDigest + } + }); + + return finalResponse; +} + +export async function login(username: string, password: string) { + const res = await digest_fetch(build_url('token'), username, password); + if (!res.ok) return false; + + const data = await res.json(); + const payload = parseJwt(data.token); + set_access_token({ token: data.token, expires: payload.exp * 1000 }); + return true; +} diff --git a/src/lib/components/Banner.svelte b/src/lib/components/Banner.svelte index d841da5..f87bd1a 100644 --- a/src/lib/components/Banner.svelte +++ b/src/lib/components/Banner.svelte @@ -1,160 +1,105 @@ -