├── .gitignore ├── dev ├── pod │ ├── .gitignore │ ├── .internal │ │ ├── setup │ │ │ ├── c2V0dXBDb21wbGV0ZWQtMi4w$.json │ │ │ ├── Y3VycmVudC1zZXJ2ZXItdmVyc2lvbg==$.json │ │ │ └── Y3VycmVudC1iYXNlLXVybA==$.json │ │ └── accounts │ │ │ ├── aHR0cDovL2xvY2FsaG9zdDozMDAwL3Byb2ZpbGUvY2FyZCNtZQ==$.json │ │ │ └── YWNjb3VudC93ZWJjbGlwJTQwbWFpbC50ZXN0$.json │ ├── .meta │ ├── README.acl │ ├── profile │ │ ├── card$.ttl │ │ └── card.acl │ ├── README$.markdown │ └── .acl ├── options.html ├── options.tsx ├── index.tsx └── index.html ├── src ├── api │ ├── now.ts │ ├── generateUuid.ts │ ├── SolidSession.ts │ ├── generateDatePathForToday.ts │ ├── ApiContext.ts │ ├── generateDatePath.spec.ts │ ├── useSolidApis.ts │ ├── AuthenticationApi.ts │ ├── useSolidApis.spec.ts │ ├── ProfileApi.ts │ ├── AuthenticationApi.spec.ts │ └── StorageApi.ts ├── domain │ ├── Bookmark.ts │ ├── Profile.ts │ ├── PageMetaData.ts │ ├── Storage.ts │ └── messages.ts ├── assets │ ├── options.css │ ├── content.css.d.ts │ ├── index.html │ ├── options.html │ └── content.css ├── solid-client-authn-chrome-ext │ ├── time.ts │ ├── chromeExtensionLogout.ts │ ├── launchWebAuthFlow.ts │ ├── ChromeExtensionLogoutHandler.ts │ ├── ChromeExtensionRedirector.ts │ ├── Session.ts │ ├── getClientAuthentication.spec.ts │ ├── mock-idp-responses.ts │ └── ChromeExtensionRedirector.spec.ts ├── background │ ├── openOptionsPage.ts │ ├── sendMessageToActiveTab.ts │ ├── createMessageHandler.ts │ ├── activate.ts │ └── MessageHandler.ts ├── chrome │ ├── closeTab.ts │ ├── urls.spec.ts │ ├── urls.ts │ ├── sendMessage.ts │ └── sendMessage.spec.ts ├── content │ ├── authorization-page │ │ ├── isOnAuthorizationPage.ts │ │ ├── ErrorDetails.tsx │ │ ├── index.tsx │ │ ├── useAuthorization.ts │ │ ├── AuthorizationPage.tsx │ │ └── AuthorizationPage.spec.tsx │ ├── page-overlay │ │ ├── openOptions.ts │ │ ├── usePage.ts │ │ ├── prettifyUrl.ts │ │ ├── SetupButton.tsx │ │ ├── usePageData.ts │ │ ├── ToolbarContainer.tsx │ │ ├── useLogin.ts │ │ ├── prettifyUrl.spec.ts │ │ ├── useProfile.ts │ │ ├── SetupButton.spec.tsx │ │ ├── LoginButton.tsx │ │ ├── useSessionInfo.ts │ │ ├── WebClip.tsx │ │ ├── ProfileInfo.tsx │ │ ├── Toolbar.tsx │ │ ├── LoginButton.spec.tsx │ │ ├── useProfile.spec.ts │ │ ├── useSessionInfo.spec.ts │ │ ├── usePageData.spec.ts │ │ ├── useBookmark.ts │ │ ├── useLogin.spec.ts │ │ ├── ToolbarContainer.spec.tsx │ │ ├── PageContent.tsx │ │ └── PageContent.spec.tsx │ └── messaging │ │ ├── ChromeMessageListener.ts │ │ └── chromeMessageListenerContext.ts ├── types │ └── solid-namespace.d.ts ├── options │ ├── auth │ │ └── AuthenticationContext.tsx │ ├── OptionsContext.ts │ ├── connect-pod │ │ ├── useConnectPod.ts │ │ ├── ConnectPodButton.tsx │ │ ├── useLogin.ts │ │ ├── ConnectPodButton.spec.tsx │ │ ├── ConnectPodSection.tsx │ │ ├── useConnectPod.spec.ts │ │ └── useLogin.spec.ts │ ├── connection-established │ │ ├── useConnectionEstablished.ts │ │ ├── ConnectionEstablished.spec.tsx │ │ ├── ConnectionEstablished.tsx │ │ └── useConnectionEstablished.spec.ts │ ├── get-a-pod │ │ └── GetAPodSection.tsx │ ├── useChromeExtension.ts │ ├── authorization-section │ │ ├── AuthorizationSection.tsx │ │ ├── useCheckAccessPermissions.ts │ │ ├── CheckingAccessPermissions.tsx │ │ ├── GrantAccess.tsx │ │ └── AuthorizationSection.spec.tsx │ ├── messaging │ │ ├── MessageHandler.ts │ │ └── MessageHandler.spec.ts │ ├── OptionsStorage.ts │ ├── HelpSection.tsx │ ├── optionsStorageApi.ts │ ├── useOptionsPage.ts │ ├── choose-storage │ │ ├── ChooseStorage.tsx │ │ ├── useChooseStorage.ts │ │ └── ChooseStorage.spec.tsx │ ├── OptionsPage.tsx │ ├── reducer.ts │ └── OptionsStorage.spec.ts ├── test │ ├── expect.ts │ ├── givenStoreContaining.ts │ ├── apiMocks.ts │ ├── thenSparqlUpdateIsSentToUrl.ts │ └── turtleResponse.ts ├── components │ ├── ErrorMessage.tsx │ ├── Input.tsx │ ├── Button.tsx │ └── Button.spec.tsx ├── store │ ├── getContainerUrl.ts │ ├── getContainerUrl.spec.ts │ ├── createAboutStatements.ts │ ├── importToStore.ts │ ├── StorageStore.ts │ ├── BookmarkStore.ts │ ├── createRelations.ts │ ├── ProfileStore.ts │ ├── createAboutStatements.spec.ts │ ├── StorageStore.spec.ts │ └── BookmarkStore.spec.ts ├── options.tsx ├── content.tsx └── background.ts ├── chrome ├── icons │ ├── 128.png │ ├── 16.png │ └── 48.png └── manifest.json ├── .prettierrc.json ├── chrome-web-store ├── screenshot-0.png └── screenshot-1.png ├── jest.config.js ├── postcss.config.js ├── .babelrc ├── tsconfig.json ├── wallaby.js ├── .eslintrc.js ├── jest.setup.js ├── LICENSE ├── tailwind.config.js ├── webpack.dev.config.js ├── webpack.config.js ├── .github └── workflows │ └── ci-cd.yml └── Readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | .idea 4 | -------------------------------------------------------------------------------- /dev/pod/.gitignore: -------------------------------------------------------------------------------- 1 | .internal/idp/ 2 | webclip/ -------------------------------------------------------------------------------- /dev/pod/.internal/setup/c2V0dXBDb21wbGV0ZWQtMi4w$.json: -------------------------------------------------------------------------------- 1 | true -------------------------------------------------------------------------------- /src/api/now.ts: -------------------------------------------------------------------------------- 1 | export const now = (): Date => new Date(); 2 | -------------------------------------------------------------------------------- /dev/pod/.internal/setup/Y3VycmVudC1zZXJ2ZXItdmVyc2lvbg==$.json: -------------------------------------------------------------------------------- 1 | "4.0.1" -------------------------------------------------------------------------------- /dev/pod/.internal/setup/Y3VycmVudC1iYXNlLXVybA==$.json: -------------------------------------------------------------------------------- 1 | "http://localhost:3000/" -------------------------------------------------------------------------------- /src/domain/Bookmark.ts: -------------------------------------------------------------------------------- 1 | export interface Bookmark { 2 | uri: string; 3 | } 4 | -------------------------------------------------------------------------------- /dev/pod/.meta: -------------------------------------------------------------------------------- 1 | a . 2 | -------------------------------------------------------------------------------- /src/assets/options.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /chrome/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecentric/web-clip/HEAD/chrome/icons/128.png -------------------------------------------------------------------------------- /chrome/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecentric/web-clip/HEAD/chrome/icons/16.png -------------------------------------------------------------------------------- /chrome/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecentric/web-clip/HEAD/chrome/icons/48.png -------------------------------------------------------------------------------- /src/assets/content.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const cssStyles: string; 2 | export default cssStyles; 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "always", 3 | "singleQuote": true, 4 | "semi": true 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/Profile.ts: -------------------------------------------------------------------------------- 1 | export interface Profile { 2 | webId: string; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/solid-client-authn-chrome-ext/time.ts: -------------------------------------------------------------------------------- 1 | export const now = (): number => new Date().getTime(); 2 | -------------------------------------------------------------------------------- /chrome-web-store/screenshot-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecentric/web-clip/HEAD/chrome-web-store/screenshot-0.png -------------------------------------------------------------------------------- /chrome-web-store/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecentric/web-clip/HEAD/chrome-web-store/screenshot-1.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | setupFilesAfterEnv: ['./jest.setup.js'], 4 | }; 5 | -------------------------------------------------------------------------------- /src/api/generateUuid.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid'; 2 | 3 | export const generateUuid = (): string => uuid(); 4 | -------------------------------------------------------------------------------- /src/background/openOptionsPage.ts: -------------------------------------------------------------------------------- 1 | export const openOptionsPage = () => { 2 | chrome.runtime.openOptionsPage(); 3 | }; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/chrome/closeTab.ts: -------------------------------------------------------------------------------- 1 | export const closeTab: (tabId: number) => void = (tabId: number) => 2 | chrome.tabs.remove(tabId); 3 | -------------------------------------------------------------------------------- /src/domain/PageMetaData.ts: -------------------------------------------------------------------------------- 1 | export interface PageMetaData { 2 | type: 'WebPage'; 3 | url: string; 4 | name: string; 5 | } 6 | -------------------------------------------------------------------------------- /dev/pod/.internal/accounts/aHR0cDovL2xvY2FsaG9zdDozMDAwL3Byb2ZpbGUvY2FyZCNtZQ==$.json: -------------------------------------------------------------------------------- 1 | {"useIdp":true,"podBaseUrl":"http://localhost:3000/"} -------------------------------------------------------------------------------- /src/domain/Storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A storage, where data can be stored 3 | */ 4 | export class Storage { 5 | constructor(public url: string) {} 6 | } 7 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-transform-runtime"], 3 | "presets": ["@babel/preset-env", "@babel/preset-typescript", "@babel/preset-react"] 4 | } 5 | -------------------------------------------------------------------------------- /src/content/authorization-page/isOnAuthorizationPage.ts: -------------------------------------------------------------------------------- 1 | export const isOnAuthorizationPage = (extensionId: string): boolean => { 2 | return window.location.pathname.endsWith(`/.web-clip/${extensionId}`); 3 | }; 4 | -------------------------------------------------------------------------------- /src/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | -------------------------------------------------------------------------------- /dev/pod/.internal/accounts/YWNjb3VudC93ZWJjbGlwJTQwbWFpbC50ZXN0$.json: -------------------------------------------------------------------------------- 1 | {"email":"webclip@mail.test","password":"$2b$10$u5RGBWZrrsQE9LcLbnzN3eNvHL3nCylgGhaXriu7Kb3phMbD1jXSW","verified":true,"webId":"http://localhost:3000/profile/card#me"} -------------------------------------------------------------------------------- /src/assets/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /src/content/page-overlay/openOptions.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from '../../domain/messages'; 2 | import { sendMessage } from '../../chrome/sendMessage'; 3 | 4 | export const openOptions = () => 5 | sendMessage({ type: MessageType.OPEN_OPTIONS }); 6 | -------------------------------------------------------------------------------- /src/solid-client-authn-chrome-ext/chromeExtensionLogout.ts: -------------------------------------------------------------------------------- 1 | export function chromeExtensionLogout() { 2 | return new Promise((resolve) => 3 | chrome.identity.clearAllCachedAuthTokens(() => { 4 | resolve(); 5 | }) 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/types/solid-namespace.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'solid-namespace' { 2 | import { NamedNode } from 'rdflib'; 3 | 4 | export default function vocab(rdf: { 5 | namedNode: (term: string) => NamedNode; 6 | }): Record NamedNode>; 7 | } 8 | -------------------------------------------------------------------------------- /src/api/SolidSession.ts: -------------------------------------------------------------------------------- 1 | import { Session as BrowserSession } from '@inrupt/solid-client-authn-browser'; 2 | import { Session as ChromeExtensionSession } from '../solid-client-authn-chrome-ext/Session'; 3 | 4 | export type SolidSession = BrowserSession | ChromeExtensionSession; 5 | -------------------------------------------------------------------------------- /src/content/page-overlay/usePage.ts: -------------------------------------------------------------------------------- 1 | import { PageMetaData } from '../../domain/PageMetaData'; 2 | 3 | export const usePage = (): PageMetaData => { 4 | return { 5 | type: 'WebPage', 6 | url: window.location.href, 7 | name: window.document.title, 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/content/page-overlay/prettifyUrl.ts: -------------------------------------------------------------------------------- 1 | export const prettifyUrl = (url: string): string => { 2 | try { 3 | const domain = new URL(url); 4 | return domain.host; 5 | } catch (err) { 6 | console.log('error parsing pod provider url', err); 7 | return ''; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/options/auth/AuthenticationContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | export const AuthenticationContext = createContext({ 4 | session: null, 5 | redirectUrl: '', 6 | }); 7 | 8 | export const useAuthentication = () => { 9 | return useContext(AuthenticationContext); 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": true, 5 | "module": "es6", 6 | "target": "es5", 7 | "lib": ["es2021"], 8 | "jsx": "react", 9 | "allowJs": true, 10 | "allowSyntheticDefaultImports": true, 11 | "moduleResolution": "node" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/options/OptionsContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | import { Dispatch, State } from './reducer'; 3 | 4 | export const OptionsContext = createContext< 5 | { state: State; dispatch: Dispatch } | undefined 6 | >(undefined); 7 | 8 | export const useOptions = () => { 9 | return useContext(OptionsContext); 10 | }; 11 | -------------------------------------------------------------------------------- /src/background/sendMessageToActiveTab.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '../domain/messages'; 2 | 3 | export function sendMessageToActiveTab(message: Message) { 4 | chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { 5 | chrome.tabs.sendMessage(tabs[0].id, message, function (response) { 6 | console.log({ response }); 7 | }); 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/content/messaging/ChromeMessageListener.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | export class ChromeMessageListener extends EventEmitter { 4 | constructor() { 5 | super(); 6 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 7 | this.emit(request.type, request.payload); 8 | sendResponse(); 9 | }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/test/expect.ts: -------------------------------------------------------------------------------- 1 | import { st } from 'rdflib'; 2 | import { 3 | Quad_Graph, 4 | Quad_Object, 5 | Quad_Predicate, 6 | Quad_Subject, 7 | } from 'rdflib/lib/tf-types'; 8 | 9 | export function containingStatement( 10 | s: Quad_Subject, 11 | p: Quad_Predicate, 12 | o: Quad_Object, 13 | g: Quad_Graph 14 | ) { 15 | return expect.arrayContaining([st(s, p, o, g)]); 16 | } 17 | -------------------------------------------------------------------------------- /src/content/messaging/chromeMessageListenerContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | import { ChromeMessageListener } from './ChromeMessageListener'; 3 | 4 | export const ChromeMessageListenerContext = 5 | createContext(undefined); 6 | 7 | export const useChromeMessageListener = () => { 8 | return useContext(ChromeMessageListenerContext); 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Props { 4 | error: Error; 5 | } 6 | 7 | export const ErrorMessage = ({ error }: Props) => { 8 | return ( 9 |

13 | {error.message} 14 |

15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /dev/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | WebClip Options - dev 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | return { 3 | autoDetect: true, 4 | files: [ 5 | 'src/**/*.ts', 6 | 'src/**/*.tsx', 7 | '!src/**/*.spec.ts', 8 | '!src/**/*.spec.tsx', 9 | ], 10 | tests: [ 11 | 'src/**/*.spec.ts', 12 | 'src/**/*.spec.tsx', 13 | '!src/**/*.integration.spec.ts', 14 | '!src/**/*.integration.spec.tsx', 15 | ], 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/store/getContainerUrl.ts: -------------------------------------------------------------------------------- 1 | export function getContainerUrl(uri: string) { 2 | const containerUri = moveUp(uri); 3 | if (containerUri !== uri) { 4 | return containerUri; 5 | } else { 6 | return null; 7 | } 8 | } 9 | 10 | function moveUp(uri: string) { 11 | if (uri.endsWith('/')) { 12 | return new URL('..', uri).toString(); 13 | } else { 14 | return new URL('.', uri).toString(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /dev/pod/README.acl: -------------------------------------------------------------------------------- 1 | @prefix acl: . 2 | @prefix foaf: . 3 | 4 | <#public> 5 | a acl:Authorization; 6 | acl:accessTo <./README>; 7 | acl:agentClass foaf:Agent; 8 | acl:mode acl:Read. 9 | 10 | <#owner> 11 | a acl:Authorization; 12 | acl:accessTo <./README>; 13 | acl:agent ; 14 | acl:mode acl:Read, acl:Write, acl:Control. 15 | -------------------------------------------------------------------------------- /src/options.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import 'style-loader!./assets/options.css'; 5 | import { OptionsPage } from './options/OptionsPage'; 6 | import { Session } from './solid-client-authn-chrome-ext/Session'; 7 | 8 | console.log('You are in the options!'); 9 | 10 | const session = new Session(); 11 | 12 | ReactDOM.render( 13 | , 14 | document.getElementById('root') 15 | ); 16 | -------------------------------------------------------------------------------- /src/solid-client-authn-chrome-ext/launchWebAuthFlow.ts: -------------------------------------------------------------------------------- 1 | export function launchWebAuthFlow( 2 | details: { url: string; interactive: boolean }, 3 | callback: (redirectUrl?: string, error?: Error) => void 4 | ) { 5 | chrome.identity.launchWebAuthFlow(details, (redirectUrl) => { 6 | if (chrome.runtime.lastError) { 7 | callback(null, new Error(chrome.runtime.lastError.message)); 8 | } else { 9 | callback(redirectUrl); 10 | } 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/api/generateDatePathForToday.ts: -------------------------------------------------------------------------------- 1 | import { now } from './now'; 2 | 3 | export const generateDatePathForToday = (): string => { 4 | const today = now(); 5 | const year = today.getFullYear(); 6 | const month = stringifyWithLeadingZero(today.getMonth() + 1); 7 | const date = stringifyWithLeadingZero(today.getDate()); 8 | return `/${year}/${month}/${date}`; 9 | }; 10 | 11 | const stringifyWithLeadingZero = (number: number): string => { 12 | return ('0' + number).slice(-2); 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { InputHTMLAttributes } from 'react'; 2 | 3 | export const Input = (props: InputHTMLAttributes) => { 4 | const baseClasses = 5 | 'shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'; 6 | const classNames = 7 | props['aria-invalid'] === true 8 | ? `border-red-500 ${baseClasses}` 9 | : baseClasses; 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /dev/pod/profile/card$.ttl: -------------------------------------------------------------------------------- 1 | @prefix foaf: . 2 | @prefix solid: . 3 | @prefix pim: . 4 | 5 | <> 6 | a foaf:PersonalProfileDocument; 7 | foaf:maker ; 8 | foaf:primaryTopic . 9 | 10 | 11 | a 12 | foaf:Person; 13 | solid:oidcIssuer 14 | ; 15 | . 16 | -------------------------------------------------------------------------------- /src/content/authorization-page/ErrorDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Props { 4 | error: Error; 5 | } 6 | 7 | export const ErrorDetails = ({ error }: Props) => { 8 | return ( 9 |
10 |

Unfortunately something went wrong:

11 |
{error.message}
12 |
13 |
{error.stack}
14 |
15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/test/givenStoreContaining.ts: -------------------------------------------------------------------------------- 1 | import { graph, parse, Store as RdflibStore } from 'rdflib'; 2 | 3 | export function givenStoreContaining(base: string, turtle: string): MockStore { 4 | const store = graph() as MockStore; 5 | parse(turtle, store, base); 6 | store.and = (base: string, turtle: string) => { 7 | parse(turtle, store, base); 8 | return store; 9 | }; 10 | return store; 11 | } 12 | 13 | export interface MockStore extends RdflibStore { 14 | and: (base: string, turtle: string) => MockStore; 15 | } 16 | -------------------------------------------------------------------------------- /src/chrome/urls.spec.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionUrl } from './urls'; 2 | 3 | describe('ExtensionUrl', () => { 4 | it('toString returns plain string value', () => { 5 | const url = new ExtensionUrl('chrome-extension://extension-id/'); 6 | expect(url.toString()).toEqual('chrome-extension://extension-id/'); 7 | }); 8 | 9 | it('origin returns url without trailing slash', () => { 10 | const url = new ExtensionUrl('chrome-extension://extension-id/'); 11 | expect(url.origin).toEqual('chrome-extension://extension-id'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/api/ApiContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | import { ProfileApi } from './ProfileApi'; 3 | import { StorageApi } from './StorageApi'; 4 | 5 | export const ApiContext = createContext({ 6 | profileApi: null, 7 | storageApi: null, 8 | }); 9 | 10 | export const useProfileApi = (): ProfileApi => { 11 | const apis = useContext(ApiContext); 12 | return apis.profileApi; 13 | }; 14 | 15 | export const useStorageApi = (): StorageApi => { 16 | const apis = useContext(ApiContext); 17 | return apis.storageApi; 18 | }; 19 | -------------------------------------------------------------------------------- /src/chrome/urls.ts: -------------------------------------------------------------------------------- 1 | type ExtensionUrlString = `chrome-extension://${string}/`; 2 | export type ExtensionOrigin = `chrome-extension://${string}`; 3 | 4 | export class ExtensionUrl { 5 | constructor(private url: ExtensionUrlString) {} 6 | 7 | get origin(): ExtensionOrigin { 8 | return this.url.slice(0, -1) as ExtensionOrigin; 9 | } 10 | 11 | toString() { 12 | return this.url; 13 | } 14 | } 15 | 16 | export function getExtensionUrl(): ExtensionUrl { 17 | return new ExtensionUrl(chrome.extension.getURL('/') as ExtensionUrlString); 18 | } 19 | -------------------------------------------------------------------------------- /src/content/page-overlay/SetupButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from '../../components/Button'; 3 | import { openOptions } from './openOptions'; 4 | 5 | interface Props { 6 | close: () => void; 7 | } 8 | 9 | export const SetupButton = ({ close }: Props) => { 10 | return ( 11 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/content/page-overlay/usePageData.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { MessageType } from '../../domain/messages'; 3 | import { sendMessage } from '../../chrome/sendMessage'; 4 | 5 | export const usePageData = (url: string) => { 6 | const [loading, setLoading] = useState(true); 7 | useEffect(() => { 8 | sendMessage({ 9 | type: MessageType.IMPORT_PAGE_DATA, 10 | payload: { 11 | url, 12 | }, 13 | }).then(() => { 14 | setLoading(false); 15 | }); 16 | }, [url]); 17 | return { 18 | loading, 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/content/page-overlay/ToolbarContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Toolbar } from './Toolbar'; 3 | import { usePage } from './usePage'; 4 | import { usePageData } from './usePageData'; 5 | import { useProfile } from './useProfile'; 6 | 7 | export const ToolbarContainer = () => { 8 | const { loading: profileLoading, profile } = useProfile(); 9 | const { url } = usePage(); 10 | const { loading: dataLoading } = usePageData(url); 11 | return profileLoading || dataLoading ? ( 12 |

Loading...

13 | ) : ( 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/options/connect-pod/useConnectPod.ts: -------------------------------------------------------------------------------- 1 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 2 | import { useOptions } from '../OptionsContext'; 3 | import { ActionType } from '../reducer'; 4 | 5 | export const useConnectPod = () => { 6 | const { dispatch, state } = useOptions(); 7 | return { 8 | setProviderUrl: (url: string) => 9 | dispatch({ type: ActionType.SET_PROVIDER_URL, payload: url }), 10 | providerUrl: state.value.providerUrl, 11 | onLogin: (sessionInfo: ISessionInfo) => 12 | dispatch({ type: ActionType.LOGGED_IN, payload: sessionInfo }), 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/solid-client-authn-chrome-ext/ChromeExtensionLogoutHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ILogoutHandler, 3 | ISessionInfoManager, 4 | } from '@inrupt/solid-client-authn-core'; 5 | import { chromeExtensionLogout } from './chromeExtensionLogout'; 6 | 7 | export class ChromeExtensionLogoutHandler implements ILogoutHandler { 8 | constructor(private sessionInfoManager: ISessionInfoManager) {} 9 | 10 | async canHandle(): Promise { 11 | return true; 12 | } 13 | 14 | async handle(userId: string): Promise { 15 | await chromeExtensionLogout(); 16 | await this.sessionInfoManager.clear(userId); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/options/connection-established/useConnectionEstablished.ts: -------------------------------------------------------------------------------- 1 | import { useAuthentication } from '../auth/AuthenticationContext'; 2 | import { useOptions } from '../OptionsContext'; 3 | import { ActionType } from '../reducer'; 4 | 5 | export const useConnectionEstablished = () => { 6 | const { state, dispatch } = useOptions(); 7 | const { session } = useAuthentication(); 8 | return { 9 | providerUrl: state.value.providerUrl, 10 | containerUrl: state.value.containerUrl, 11 | disconnect: async () => { 12 | await session.logout(); 13 | dispatch({ type: ActionType.DISCONNECTED_POD }); 14 | }, 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/chrome/sendMessage.ts: -------------------------------------------------------------------------------- 1 | import { Message, Response } from '../domain/messages'; 2 | 3 | export async function sendMessage(message: Message) { 4 | return new Promise((resolve, reject) => { 5 | chrome.runtime.sendMessage(message, function (response?: Response) { 6 | if (chrome.runtime.lastError) { 7 | reject(chrome.runtime.lastError); 8 | } else if (!response) { 9 | reject(new Error(`response to ${message.type} message was null`)); 10 | } else if (response.errorMessage) { 11 | reject(new Error(response.errorMessage)); 12 | } else { 13 | resolve(response.payload); 14 | } 15 | }); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/options/get-a-pod/GetAPodSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const GetAPodSection = () => ( 4 |
5 |

6 | For information on how to create your own pod, please visit{' '} 7 | 11 | the Solid Project 12 | {' '} 13 | or just create a pod on{' '} 14 | 18 | solidcommunity.net 19 | 20 |

21 |
22 | ); 23 | -------------------------------------------------------------------------------- /dev/pod/profile/card.acl: -------------------------------------------------------------------------------- 1 | # ACL resource for the WebID profile document 2 | @prefix acl: . 3 | @prefix foaf: . 4 | 5 | # The WebID profile is readable by the public. 6 | # This is required for discovery and verification, 7 | # e.g. when checking identity providers. 8 | <#public> 9 | a acl:Authorization; 10 | acl:agentClass foaf:Agent; 11 | acl:accessTo <./card>; 12 | acl:mode acl:Read. 13 | 14 | # The owner has full access to the profile 15 | <#owner> 16 | a acl:Authorization; 17 | acl:agent ; 18 | acl:accessTo <./card>; 19 | acl:mode acl:Read, acl:Write, acl:Control. 20 | -------------------------------------------------------------------------------- /src/api/generateDatePath.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateDatePathForToday } from './generateDatePathForToday'; 2 | import { now } from './now'; 3 | 4 | jest.mock('./now'); 5 | 6 | describe('generateDatePathForToday', () => { 7 | it.each([ 8 | ['2021-03-12', '/2021/03/12'], 9 | ['2021-12-03', '/2021/12/03'], 10 | ['2021-11-12', '/2021/11/12'], 11 | ['2021-03-01', '/2021/03/01'], 12 | ])( 13 | 'for a given date %s it returns a path %s with the format year/month/date', 14 | (date: string, path: string) => { 15 | (now as jest.Mock).mockReturnValue(new Date(date)); 16 | 17 | const result = generateDatePathForToday(); 18 | 19 | expect(result).toEqual(path); 20 | } 21 | ); 22 | }); 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | browser: true, 5 | es2021: true, 6 | jest: true, 7 | webextensions: true, 8 | }, 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:react/recommended', 12 | 'plugin:@typescript-eslint/recommended', 13 | ], 14 | settings: { 15 | react: { 16 | version: 'detect', 17 | }, 18 | }, 19 | parser: '@typescript-eslint/parser', 20 | parserOptions: { 21 | ecmaFeatures: { 22 | jsx: true, 23 | }, 24 | ecmaVersion: 12, 25 | sourceType: 'module', 26 | }, 27 | plugins: ['react', '@typescript-eslint'], 28 | rules: { 29 | '@typescript-eslint/explicit-module-boundary-types': ['off'], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/test/apiMocks.ts: -------------------------------------------------------------------------------- 1 | export interface BockmarkApiMock { 2 | bookmark: jest.Mock; 3 | loadProfile: jest.Mock; 4 | loadBookmark: jest.Mock; 5 | } 6 | 7 | export interface AuthenticationApiMock { 8 | logout: jest.Mock; 9 | login: jest.Mock; 10 | } 11 | 12 | export function mockBookmarkApi(): BockmarkApiMock { 13 | return { 14 | bookmark: jest.fn().mockResolvedValue(undefined), 15 | loadProfile: jest.fn().mockResolvedValue(undefined), 16 | loadBookmark: jest.fn().mockResolvedValue(undefined), 17 | }; 18 | } 19 | 20 | export function mockAuthenticationApi(): AuthenticationApiMock { 21 | return { 22 | login: jest.fn().mockResolvedValue(undefined), 23 | logout: jest.fn().mockResolvedValue(undefined), 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/content/page-overlay/useLogin.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import { MessageType } from '../../domain/messages'; 3 | import { sendMessage } from '../../chrome/sendMessage'; 4 | 5 | export const useLogin = () => { 6 | const [state, setState] = useState({ 7 | loading: false, 8 | error: null, 9 | }); 10 | 11 | const login = useCallback(async () => { 12 | try { 13 | setState({ loading: true, error: null }); 14 | await sendMessage({ type: MessageType.LOGIN }); 15 | setState({ loading: false, error: null }); 16 | } catch (error) { 17 | setState({ 18 | loading: false, 19 | error, 20 | }); 21 | } 22 | }, []); 23 | return { 24 | login, 25 | ...state, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import fetch from 'node-fetch'; 3 | 4 | // Polyfill for encoding which isn't present globally in jsdom 5 | import { TextEncoder, TextDecoder } from 'util'; 6 | 7 | global.TextEncoder = TextEncoder; 8 | global.TextDecoder = TextDecoder; 9 | 10 | window.crypto = { 11 | // use a fixed random value to be able to determine the state parameter for the auth flow, 12 | // which will be 01111111011141119111011111111111 for Uint8Array.of(1, 2, 3, 4) 13 | getRandomValues: () => Uint8Array.of(1, 2, 3, 4), 14 | }; 15 | 16 | window.fetch = fetch; 17 | 18 | global.chrome = { 19 | runtime: { 20 | sendMessage: jest.fn(), 21 | }, 22 | identity: { 23 | getRedirectURL: () => 'https://redirect-url.test', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/content/page-overlay/prettifyUrl.spec.ts: -------------------------------------------------------------------------------- 1 | import { prettifyUrl } from './prettifyUrl'; 2 | 3 | describe('prettify url', () => { 4 | it('strips https:// from url', () => { 5 | const result = prettifyUrl('https://pod.provider'); 6 | expect(result).toEqual('pod.provider'); 7 | }); 8 | 9 | it('strips http:// from url', () => { 10 | const result = prettifyUrl('http://pod.provider'); 11 | expect(result).toEqual('pod.provider'); 12 | }); 13 | 14 | it('removes trailing slash', () => { 15 | const result = prettifyUrl('http://pod.provider/'); 16 | expect(result).toEqual('pod.provider'); 17 | }); 18 | 19 | it('returns empty string for non-urls', () => { 20 | const result = prettifyUrl('invalid url'); 21 | expect(result).toEqual(''); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/options/connect-pod/ConnectPodButton.tsx: -------------------------------------------------------------------------------- 1 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 2 | import React from 'react'; 3 | import { Button } from '../../components/Button'; 4 | import { ErrorMessage } from '../../components/ErrorMessage'; 5 | import { useLogin } from './useLogin'; 6 | 7 | interface Props { 8 | oidcIssuer: string; 9 | onLogin: (sessionInfo: ISessionInfo) => void; 10 | } 11 | export const ConnectPodButton = ({ oidcIssuer, onLogin }: Props) => { 12 | const { login, error, loading } = useLogin(oidcIssuer, onLogin); 13 | 14 | return ( 15 | <> 16 | 19 | {error && } 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/content/page-overlay/useProfile.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { MessageType } from '../../domain/messages'; 3 | import { sendMessage } from '../../chrome/sendMessage'; 4 | import { Profile } from '../../domain/Profile'; 5 | 6 | interface AsyncState { 7 | loading: boolean; 8 | value?: T; 9 | } 10 | 11 | export const useProfile = () => { 12 | const [{ loading, value }, setState] = useState>({ 13 | loading: true, 14 | }); 15 | 16 | useEffect(() => { 17 | sendMessage({ type: MessageType.LOAD_PROFILE }).then((profile: Profile) => 18 | setState({ 19 | loading: false, 20 | value: profile, 21 | }) 22 | ); 23 | }, []); 24 | 25 | return { 26 | loading, 27 | profile: value, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/content/page-overlay/SetupButton.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { fireEvent, render, screen } from '@testing-library/react'; 3 | import { MessageType } from '../../domain/messages'; 4 | import { sendMessage } from '../../chrome/sendMessage'; 5 | import { SetupButton } from './SetupButton'; 6 | 7 | jest.mock('../../chrome/sendMessage'); 8 | 9 | describe('SetupButton', () => { 10 | it('triggers open option page message and calls close when clicked', () => { 11 | const close = jest.fn(); 12 | render(); 13 | const button = screen.getByText('Get started'); 14 | fireEvent.click(button); 15 | expect(sendMessage).toHaveBeenCalledWith({ 16 | type: MessageType.OPEN_OPTIONS, 17 | }); 18 | expect(close).toHaveBeenCalled(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | interface Props { 4 | loading: boolean; 5 | loadingLabel: string; 6 | onClick: () => void; 7 | } 8 | 9 | export const Button: FunctionComponent = ({ 10 | children, 11 | onClick, 12 | loading, 13 | loadingLabel, 14 | }) => { 15 | const loadingClass = 16 | 'whitespace-nowrap animate-pulse my-1 px-4 py-2 bg-green-400 rounded text-white font-bold'; 17 | const defaultClass = 18 | 'whitespace-nowrap my-1 px-4 py-2 bg-blue-400 rounded text-white hover:opacity-90 font-bold'; 19 | return ( 20 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/content/page-overlay/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from '../../components/Button'; 3 | import { ErrorMessage } from '../../components/ErrorMessage'; 4 | import { prettifyUrl } from './prettifyUrl'; 5 | import { useLogin } from './useLogin'; 6 | 7 | interface Props { 8 | providerUrl: string; 9 | } 10 | 11 | export const LoginButton = ({ providerUrl }: Props) => { 12 | const { login, loading, error } = useLogin(); 13 | return ( 14 |
15 | 18 |

19 | {prettifyUrl(providerUrl)} 20 |

21 | {error && } 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/background/createMessageHandler.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationApi } from '../api/AuthenticationApi'; 2 | import { BookmarkApi } from '../api/BookmarkApi'; 3 | import { OptionsStorage } from '../options/OptionsStorage'; 4 | import { Session } from '../solid-client-authn-chrome-ext/Session'; 5 | import { BookmarkStore } from '../store/BookmarkStore'; 6 | import { MessageHandler } from './MessageHandler'; 7 | 8 | export function createMessageHandler( 9 | session: Session, 10 | optionsStorage: OptionsStorage 11 | ) { 12 | const store = new BookmarkStore(); 13 | return new MessageHandler( 14 | session, 15 | new BookmarkApi(session, store, optionsStorage), 16 | store, 17 | new AuthenticationApi( 18 | session, 19 | optionsStorage, 20 | chrome.identity.getRedirectURL() 21 | ) 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "WebClip - Clip all the things", 4 | "short_name": "WebClip", 5 | "description": "Store all the interesting things around the Web on your personal online datastore", 6 | "icons": { 7 | "16": "icons/16.png", 8 | "48": "icons/48.png", 9 | "128": "icons/128.png" 10 | }, 11 | "browser_action": { 12 | "default_title": "WebClip" 13 | }, 14 | "background": { 15 | "scripts": [ 16 | "background.js" 17 | ] 18 | }, 19 | "content_scripts": [ 20 | { 21 | "matches": [ 22 | "https://*/*" 23 | ], 24 | "js": [ 25 | "content.js" 26 | ] 27 | } 28 | ], 29 | "permissions": [ 30 | "storage", 31 | "identity" 32 | ], 33 | "web_accessible_resources": [], 34 | "options_page": "options.html" 35 | } 36 | -------------------------------------------------------------------------------- /dev/pod/README$.markdown: -------------------------------------------------------------------------------- 1 | # Welcome to your pod 2 | 3 | ## A place to store your data 4 | Your pod is a **secure storage space** for your documents and data. 5 |
6 | You can choose to share those with other people and apps. 7 | 8 | As the owner of this pod, 9 | identified by http://localhost:3000/profile/card#me, 10 | you have access to all of your documents. 11 | 12 | ## Working with your pod 13 | The easiest way to interact with pods 14 | is through Solid apps. 15 |
16 | For example, 17 | you can open your pod in [Databrowser](https://solid.github.io/mashlib/dist/browse.html?uri=http://localhost:3000/). 18 | 19 | ## Learn more 20 | The [Solid website](https://solidproject.org/) 21 | and the people on its [forum](https://forum.solidproject.org/) 22 | will be glad to help you on your journey. 23 | -------------------------------------------------------------------------------- /src/content/page-overlay/useSessionInfo.ts: -------------------------------------------------------------------------------- 1 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 2 | import { useEffect, useState } from 'react'; 3 | import { MessageType } from '../../domain/messages'; 4 | import { ChromeMessageListener } from '../messaging/ChromeMessageListener'; 5 | import { useChromeMessageListener } from '../messaging/chromeMessageListenerContext'; 6 | 7 | export function useSessionInfo(initial: ISessionInfo): ISessionInfo { 8 | const [sessionInfo, setSessionInfo] = useState(initial); 9 | 10 | const chromeMessageListener: ChromeMessageListener = 11 | useChromeMessageListener(); 12 | 13 | useEffect(() => { 14 | chromeMessageListener?.on(MessageType.LOGGED_IN, (info) => { 15 | setSessionInfo(info); 16 | }); 17 | }, [chromeMessageListener]); 18 | 19 | return sessionInfo; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Button.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | import { Button } from './Button'; 4 | 5 | describe('Button', () => { 6 | it('triggers click handler when clicked', () => { 7 | const onClick = jest.fn(); 8 | render( 9 | 12 | ); 13 | const button = screen.getByText('Click me'); 14 | fireEvent.click(button); 15 | expect(onClick).toHaveBeenCalled(); 16 | }); 17 | 18 | it('shows loading label when loading', () => { 19 | render( 20 | 23 | ); 24 | expect(screen.getByText('Loading')).toBeInTheDocument(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/background/activate.ts: -------------------------------------------------------------------------------- 1 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 2 | import { MessageType } from '../domain/messages'; 3 | import Tab = chrome.tabs.Tab; 4 | 5 | export function activateWebClipForTab( 6 | tab: Tab, 7 | sessionInfo: ISessionInfo, 8 | providerUrl: string 9 | ) { 10 | chrome.tabs.sendMessage( 11 | tab.id, 12 | { type: MessageType.ACTIVATE, payload: { ...sessionInfo, providerUrl } }, 13 | function () { 14 | if (chrome.runtime.lastError) { 15 | alert( 16 | 'WebClip does only work on secure web pages (starting with https://)' 17 | ); 18 | } 19 | return null; 20 | } 21 | ); 22 | } 23 | 24 | export function deactivateWebClipForTab(tab: Tab) { 25 | chrome.tabs.sendMessage( 26 | tab.id, 27 | { type: MessageType.DEACTIVATE }, 28 | function () { 29 | return null; 30 | } 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/api/useSolidApis.ts: -------------------------------------------------------------------------------- 1 | import { Fetcher, graph, LiveStore, UpdateManager } from 'rdflib'; 2 | import { useEffect, useState } from 'react'; 3 | import { ProfileApi } from './ProfileApi'; 4 | import { SolidSession } from './SolidSession'; 5 | import { StorageApi } from './StorageApi'; 6 | 7 | function initializeApis(session: SolidSession) { 8 | const store = graph() as LiveStore; 9 | new UpdateManager(store); 10 | new Fetcher(store, { fetch: session.fetch }); 11 | return { 12 | profileApi: new ProfileApi(session, store), 13 | storageApi: new StorageApi(session.info.webId, store), 14 | }; 15 | } 16 | 17 | export const useSolidApis = (session: SolidSession) => { 18 | const [apis, setApis] = useState(() => { 19 | return initializeApis(session); 20 | }); 21 | 22 | useEffect(() => { 23 | session.onLogin(() => { 24 | setApis(initializeApis(session)); 25 | }); 26 | }, []); 27 | 28 | return apis; 29 | }; 30 | -------------------------------------------------------------------------------- /src/content/page-overlay/WebClip.tsx: -------------------------------------------------------------------------------- 1 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 2 | import React from 'react'; 3 | import { ChromeMessageListener } from '../messaging/ChromeMessageListener'; 4 | 5 | import { ChromeMessageListenerContext } from '../messaging/chromeMessageListenerContext'; 6 | import { PageContent } from './PageContent'; 7 | 8 | interface Props { 9 | chromeMessageListener: ChromeMessageListener; 10 | sessionInfo: ISessionInfo; 11 | providerUrl: string; 12 | close: () => void; 13 | } 14 | 15 | export const WebClip = ({ 16 | chromeMessageListener, 17 | sessionInfo, 18 | providerUrl, 19 | close, 20 | }: Props) => { 21 | return ( 22 | 23 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/store/getContainerUrl.spec.ts: -------------------------------------------------------------------------------- 1 | import { getContainerUrl } from './getContainerUrl'; 2 | 3 | describe('getContainerUrl', () => { 4 | it('returns the url of the container containing the WebID profile', () => { 5 | const result = getContainerUrl( 6 | 'https://provider.test/alice/profile/card#me' 7 | ); 8 | expect(result).toEqual('https://provider.test/alice/profile/'); 9 | }); 10 | 11 | it('returns the url of the parent container', () => { 12 | const result = getContainerUrl('https://provider.test/alice/profile/'); 13 | expect(result).toEqual('https://provider.test/alice/'); 14 | }); 15 | 16 | it('returns the root container', () => { 17 | const result = getContainerUrl('https://provider.test/alice/'); 18 | expect(result).toEqual('https://provider.test/'); 19 | }); 20 | 21 | it('returns null if no further parent exists', () => { 22 | const result = getContainerUrl('https://provider.test/'); 23 | expect(result).toEqual(null); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/options/useChromeExtension.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { getExtensionUrl } from '../chrome/urls'; 3 | import { Response } from '../domain/messages'; 4 | import { MessageHandler } from './messaging/MessageHandler'; 5 | 6 | export const useChromeExtension = (messageHandler: MessageHandler) => { 7 | useEffect(() => { 8 | chrome.runtime.onMessage.addListener(function ( 9 | request, 10 | sender, 11 | sendResponse: (response: Response) => void 12 | ) { 13 | const result = messageHandler.handleMessage(request, sender); 14 | 15 | if (result instanceof Promise) { 16 | result 17 | .then(sendResponse) 18 | .catch((error) => sendResponse({ errorMessage: error.toString() })); 19 | return true; // indicate async response 20 | } 21 | return result; 22 | }); 23 | }, []); 24 | 25 | return { 26 | redirectUrl: new URL(chrome.identity.getRedirectURL()), 27 | extensionUrl: getExtensionUrl(), 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /dev/pod/.acl: -------------------------------------------------------------------------------- 1 | # Root ACL resource for the agent account 2 | @prefix acl: . 3 | @prefix foaf: . 4 | 5 | # The homepage is readable by the public 6 | <#public> 7 | a acl:Authorization; 8 | acl:agentClass foaf:Agent; 9 | acl:accessTo <./>; 10 | acl:mode acl:Read. 11 | 12 | # The owner has full access to every resource in their pod. 13 | # Other agents have no access rights, 14 | # unless specifically authorized in other .acl resources. 15 | <#owner> 16 | a acl:Authorization; 17 | acl:agent ; 18 | # Optional owner email, to be used for account recovery: 19 | acl:agent ; 20 | # Set the access to the root storage folder itself 21 | acl:accessTo <./>; 22 | # All resources will inherit this authorization, by default 23 | acl:default <./>; 24 | # The owner has all of the access modes allowed 25 | acl:mode 26 | acl:Read, acl:Write, acl:Control. 27 | -------------------------------------------------------------------------------- /src/options/connect-pod/useLogin.ts: -------------------------------------------------------------------------------- 1 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 2 | import { useState } from 'react'; 3 | import { useAuthentication } from '../auth/AuthenticationContext'; 4 | 5 | export const useLogin = ( 6 | oidcIssuer: string, 7 | onLogin: (sessionInfo: ISessionInfo) => void 8 | ) => { 9 | const [state, setState] = useState({ 10 | loading: false, 11 | error: null, 12 | }); 13 | const { session, redirectUrl } = useAuthentication(); 14 | 15 | const login = async () => { 16 | setState({ 17 | loading: true, 18 | error: null, 19 | }); 20 | try { 21 | await session.login({ 22 | oidcIssuer, 23 | redirectUrl, 24 | }); 25 | setState({ 26 | loading: false, 27 | error: null, 28 | }); 29 | await onLogin(session.info); 30 | } catch (error) { 31 | setState({ 32 | loading: false, 33 | error, 34 | }); 35 | } 36 | }; 37 | 38 | return { 39 | ...state, 40 | login, 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/options/authorization-section/AuthorizationSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ExtensionUrl } from '../../chrome/urls'; 3 | import { CheckingAccessPermissions } from './CheckingAccessPermissions'; 4 | import { GrantAccess } from './GrantAccess'; 5 | import { useCheckAccessPermissions } from './useCheckAccessPermissions'; 6 | 7 | interface Props { 8 | extensionUrl: ExtensionUrl; 9 | redirectUrl: URL; 10 | } 11 | 12 | export const AuthorizationSection = ({ extensionUrl, redirectUrl }: Props) => { 13 | const { checking, profileApi } = useCheckAccessPermissions( 14 | extensionUrl, 15 | redirectUrl 16 | ); 17 | 18 | return ( 19 |
20 |

Authorize WebClip

21 | {checking ? ( 22 | 23 | ) : ( 24 | 28 | )} 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/options/authorization-section/useCheckAccessPermissions.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useProfileApi } from '../../api/ApiContext'; 3 | import { ExtensionUrl } from '../../chrome/urls'; 4 | import { useOptions } from '../OptionsContext'; 5 | import { ActionType } from '../reducer'; 6 | 7 | export const useCheckAccessPermissions = ( 8 | extensionUrl: ExtensionUrl, 9 | redirectUrl: URL 10 | ) => { 11 | const { state, dispatch } = useOptions(); 12 | const profileApi = useProfileApi(); 13 | 14 | const [checking, setChecking] = useState(false); 15 | 16 | useEffect(() => { 17 | if (state.sessionInfo.isLoggedIn) { 18 | setChecking(true); 19 | profileApi 20 | .canExtensionAccessPod(extensionUrl, redirectUrl) 21 | .then((trusted) => { 22 | setChecking(false); 23 | if (trusted) { 24 | dispatch({ type: ActionType.TRUSTED_APP }); 25 | } 26 | }); 27 | } 28 | }, [state.sessionInfo.isLoggedIn]); 29 | 30 | return { 31 | checking, 32 | profileApi, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/content/page-overlay/ProfileInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Profile } from '../../domain/Profile'; 3 | 4 | export const ProfileInfo = ({ webId, name }: Profile) => { 5 | return ( 6 |
7 |
8 | 9 | {webId} 10 | 11 | 19 | 24 | 25 | {name} 26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/options/messaging/MessageHandler.ts: -------------------------------------------------------------------------------- 1 | import { closeTab } from '../../chrome/closeTab'; 2 | import { Message, MessageType, Response } from '../../domain/messages'; 3 | import { ActionType, Dispatch } from '../reducer'; 4 | import MessageSender = chrome.runtime.MessageSender; 5 | 6 | export class MessageHandler { 7 | constructor(private readonly dispatch: Dispatch) {} 8 | 9 | /** 10 | * Handles the given request, if it is relevant for the option page and return and aysnc response. 11 | * Returns false synchronously, if the given request is not handled. 12 | * 13 | * @param request 14 | * @param sender 15 | */ 16 | handleMessage( 17 | request: Message, 18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 19 | sender: MessageSender 20 | ): boolean | Promise { 21 | switch (request.type) { 22 | case MessageType.ACCESS_GRANTED: 23 | closeTab(sender.tab.id); 24 | this.dispatch({ 25 | type: ActionType.TRUSTED_APP, 26 | }); 27 | return Promise.resolve({}); 28 | } 29 | return false; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/thenSparqlUpdateIsSentToUrl.ts: -------------------------------------------------------------------------------- 1 | import { Parser as SparqlParser, Update } from 'sparqljs'; 2 | 3 | export function thenSparqlUpdateIsSentToUrl( 4 | fetchMock: jest.Mock, 5 | url: string, 6 | query: string 7 | ) { 8 | expect(fetchMock).toHaveBeenCalled(); 9 | 10 | const parser = new SparqlParser(); 11 | 12 | const calls = (fetchMock as jest.Mock).mock.calls; 13 | const sparqlUpdateCall = calls.find( 14 | (it) => it[0] === url && it[1].method === 'PATCH' 15 | ); 16 | 17 | expect(sparqlUpdateCall).toBeDefined(); 18 | 19 | const body = sparqlUpdateCall[1].body; 20 | const actualQuery = parser.parse(body) as Update; 21 | const expectedQuery = parser.parse(query) as Update; 22 | expect(actualQuery).toEqual(expectedQuery); 23 | } 24 | 25 | export function thenNoSparqlUpdateIsSentToUrl( 26 | authenticatedFetch: jest.Mock, 27 | url: string 28 | ) { 29 | const calls = authenticatedFetch.mock.calls; 30 | const sparqlUpdateCall = calls.find( 31 | (it) => it[0] === url && it[1].method === 'PATCH' 32 | ); 33 | 34 | expect(sparqlUpdateCall).not.toBeDefined(); 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 codecentric AG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/api/AuthenticationApi.ts: -------------------------------------------------------------------------------- 1 | import { ILoginInputOptions } from '@inrupt/solid-client-authn-browser'; 2 | import { OptionsStorage } from '../options/OptionsStorage'; 3 | import { SolidSession } from './SolidSession'; 4 | 5 | export class AuthenticationApi { 6 | private readonly session: SolidSession; 7 | 8 | private readonly redirectUrl: string; 9 | private readonly optionsStorage: OptionsStorage; 10 | 11 | constructor( 12 | session: SolidSession, 13 | optionsStorage: OptionsStorage, 14 | redirectUrl: string = window.location.href 15 | ) { 16 | this.session = session; 17 | this.redirectUrl = redirectUrl; 18 | this.optionsStorage = optionsStorage; 19 | } 20 | 21 | async login(options: ILoginInputOptions = {}) { 22 | const { providerUrl } = this.optionsStorage.getOptions(); 23 | if (!providerUrl) { 24 | throw new Error('No pod provider URL configured'); 25 | } 26 | return this.session.login({ 27 | oidcIssuer: providerUrl, 28 | redirectUrl: this.redirectUrl, 29 | ...options, 30 | }); 31 | } 32 | 33 | async logout() { 34 | await this.session.logout(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/assets/content.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | 4 | .tooltip { 5 | @apply invisible absolute; 6 | } 7 | 8 | .has-tooltip:hover .tooltip { 9 | @apply visible z-max; 10 | } 11 | 12 | @tailwind utilities; 13 | 14 | .paperclip { 15 | padding: 0; 16 | background: transparent; 17 | box-sizing: border-box; 18 | display: block; 19 | top: 30%; 20 | left: 0; 21 | margin-top: -40px; 22 | margin-left: -19px; 23 | height: 80px; 24 | width: 38px; 25 | border: 3px groove #DDDDDD; 26 | border-radius: 0 0 90px 90px; 27 | border-top: none; 28 | position: absolute; 29 | transform: rotate(7deg); 30 | -webkit-transform: rotate(7deg); 31 | -webkit-backface-visibility: hidden; 32 | } 33 | .paperclip::before, .paperclip::after { 34 | content:''; 35 | position: absolute; 36 | border: 3px groove #DDDDDD; 37 | } 38 | .paperclip::before { 39 | left: 3px; 40 | height: 50px; 41 | width: 20px; 42 | border-radius: 0 0 90px 90px; 43 | border-top: none; 44 | } 45 | .paperclip::after { 46 | top: -30px; 47 | left: -3px; 48 | height: 30px; 49 | width: 26px; 50 | border-radius: 90px 90px 0 0; 51 | border-bottom: none; 52 | } 53 | -------------------------------------------------------------------------------- /src/content/page-overlay/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from '../../components/Button'; 3 | import { ErrorMessage } from '../../components/ErrorMessage'; 4 | import { Profile } from '../../domain/Profile'; 5 | import { ProfileInfo } from './ProfileInfo'; 6 | import { useBookmark } from './useBookmark'; 7 | import { usePage } from './usePage'; 8 | 9 | interface Props { 10 | profile: Profile; 11 | } 12 | 13 | export const Toolbar = ({ profile }: Props) => { 14 | const page = usePage(); 15 | const { addBookmark, loading, saving, error, bookmark } = useBookmark(page); 16 | return ( 17 | <> 18 | {loading ? null : ( 19 | 26 | )} 27 | {error && } 28 | {bookmark && ( 29 |

30 | 31 | Show in pod 32 | 33 |

34 | )} 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/content/authorization-page/index.tsx: -------------------------------------------------------------------------------- 1 | import { Session } from '@inrupt/solid-client-authn-browser'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { ExtensionUrl } from '../../chrome/urls'; 5 | import { load as loadOptions } from '../../options/optionsStorageApi'; 6 | import { AuthorizationPage } from './AuthorizationPage'; 7 | 8 | async function handleRedirectAfterLogin() { 9 | const session = new Session({ 10 | sessionInfo: { 11 | sessionId: 'webclip-auth-session', 12 | isLoggedIn: false, 13 | }, 14 | }); 15 | await session.handleIncomingRedirect(); 16 | return session; 17 | } 18 | 19 | export async function renderAuthorizationPage( 20 | extensionUrl: ExtensionUrl, 21 | root: HTMLElement, 22 | container: HTMLElement 23 | ) { 24 | const session = await handleRedirectAfterLogin(); 25 | 26 | const { providerUrl } = await loadOptions(); 27 | 28 | console.log( 29 | 'This is the WebClip authorization page for extension', 30 | extensionUrl 31 | ); 32 | document.body.replaceChildren(root); 33 | ReactDOM.render( 34 | , 39 | container 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/options/OptionsStorage.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { load, onChanged, Options } from './optionsStorageApi'; 3 | 4 | /** 5 | * Provides access to the current values of the extension options. 6 | * Emits an event with the same name as the option, when an option changes. 7 | */ 8 | export class OptionsStorage extends EventEmitter { 9 | private value: Options; 10 | 11 | constructor() { 12 | super(); 13 | } 14 | 15 | /** 16 | * Initially loads the option values from storage 17 | */ 18 | async init() { 19 | this.value = await load(); 20 | this.updateWhenChanged(); 21 | } 22 | 23 | private updateWhenChanged() { 24 | onChanged((changes, namespace) => { 25 | if (namespace !== 'sync') return; 26 | const keys = Object.keys(changes); 27 | const result = keys.reduce( 28 | (acc, value) => ({ 29 | ...acc, 30 | [value]: changes[value].newValue, 31 | }), 32 | {} 33 | ); 34 | keys.forEach((key) => this.emit(key, changes[key])); 35 | this.value = { 36 | ...this.value, 37 | ...result, 38 | }; 39 | }); 40 | } 41 | 42 | /** 43 | * The current option values 44 | */ 45 | getOptions(): Options { 46 | return this.value; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.{html,tsx}', './dev/**/*.{html,tsx}'], 3 | theme: { 4 | fontSize: { 5 | xs: ['12px', '16px'], 6 | sm: ['14px', '20px'], 7 | base: ['16px', '24px'], 8 | lg: ['18px', '26px'], 9 | xl: ['20px', '28px'], 10 | '2xl': ['24px', '32px'], 11 | '3xl': ['30px', '38px'], 12 | }, 13 | spacing: { 14 | px: '1px', 15 | 0: '0', 16 | 0.5: '2px', 17 | 1: '4px', 18 | 1.5: '6px', 19 | 2: '8px', 20 | 2.5: '10px', 21 | 3: '12px', 22 | 3.5: '14px', 23 | 4: '16px', 24 | 5: '20px', 25 | 6: '24px', 26 | 7: '28px', 27 | 8: '32px', 28 | 9: '36px', 29 | 10: '40px', 30 | 11: '44px', 31 | 12: '48px', 32 | 14: '56px', 33 | 16: '64px', 34 | 20: '80px', 35 | 24: '96px', 36 | 28: '112px', 37 | 32: '128px', 38 | 36: '144px', 39 | 40: '160px', 40 | 44: '176px', 41 | 48: '192px', 42 | 52: '208px', 43 | 56: '224px', 44 | 60: '240px', 45 | 64: '256px', 46 | 72: '288px', 47 | 80: '320px', 48 | 96: '384px', 49 | }, 50 | extend: { 51 | zIndex: { 52 | max: 2147483647, 53 | }, 54 | }, 55 | }, 56 | plugins: [], 57 | }; 58 | -------------------------------------------------------------------------------- /src/options/connect-pod/ConnectPodButton.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react'; 2 | import { when } from 'jest-when'; 3 | import React from 'react'; 4 | import { ConnectPodButton } from './ConnectPodButton'; 5 | import { useLogin } from './useLogin'; 6 | 7 | jest.mock('./useLogin'); 8 | 9 | describe('LoginButton', () => { 10 | it('triggers login when clicked', () => { 11 | const login = jest.fn(); 12 | when(useLogin) 13 | .calledWith('http://pod.test', expect.anything()) 14 | .mockReturnValue({ 15 | loading: false, 16 | error: null, 17 | login, 18 | }); 19 | render( 20 | null} /> 21 | ); 22 | const button = screen.getByText('Connect Pod'); 23 | fireEvent.click(button); 24 | expect(login).toHaveBeenCalled(); 25 | }); 26 | 27 | it('indicates loading', () => { 28 | when(useLogin) 29 | .calledWith('http://pod.test', expect.anything()) 30 | .mockReturnValue({ 31 | loading: true, 32 | error: null, 33 | login: jest.fn(), 34 | }); 35 | render( 36 | null} /> 37 | ); 38 | expect(screen.getByText('Signing in')).toBeInTheDocument(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/store/createAboutStatements.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BlankNode, 3 | isNamedNode, 4 | NamedNode, 5 | st, 6 | Statement, 7 | sym, 8 | Variable, 9 | } from 'rdflib'; 10 | 11 | function isSameThing(it: BlankNode | NamedNode | Variable, pageUrl: NamedNode) { 12 | return isNamedNode(it) && it.uri === pageUrl.uri; 13 | } 14 | 15 | export function createAboutStatements( 16 | pageUrl: NamedNode, 17 | statements: Statement[], 18 | targetDocument: NamedNode 19 | ) { 20 | const topLevelSubjects = findTopLevelSubject(pageUrl, statements); 21 | return topLevelSubjects.map((it) => 22 | st(pageUrl, sym('http://schema.org/about'), it, targetDocument) 23 | ); 24 | } 25 | 26 | function findTopLevelSubject(pageUrl: NamedNode, statements: Statement[]) { 27 | const uniqueSubjects = findUniqueThings(pageUrl, statements); 28 | const referencedThings = findThingsReferencedByOthers(statements); 29 | return uniqueSubjects.filter( 30 | (subject) => !referencedThings.includes(subject) 31 | ); 32 | } 33 | 34 | function findUniqueThings(pageUrl: NamedNode, statements: Statement[]) { 35 | return Array.from(new Set(statements.map((it) => it.subject))).filter( 36 | (it) => !isSameThing(it, pageUrl) 37 | ); 38 | } 39 | 40 | function findThingsReferencedByOthers(statements: Statement[]) { 41 | return Array.from(new Set(statements.map((it) => it.object))); 42 | } 43 | -------------------------------------------------------------------------------- /src/options/connection-established/ConnectionEstablished.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { when } from 'jest-when'; 4 | import React from 'react'; 5 | 6 | import { ConnectionEstablished } from './ConnectionEstablished'; 7 | import { useConnectionEstablished } from './useConnectionEstablished'; 8 | 9 | jest.mock('./useConnectionEstablished'); 10 | 11 | describe('ConnectionEstablished', () => { 12 | it('shows the connected provider url', () => { 13 | when(useConnectionEstablished).mockReturnValue({ 14 | providerUrl: 'https://pod.test', 15 | containerUrl: 'https://pod.test/alice/webclip', 16 | disconnect: () => null, 17 | }); 18 | render(); 19 | expect(screen.queryByText('https://pod.test')).toBeInTheDocument(); 20 | }); 21 | 22 | it('disconnects when clicking the respective link', () => { 23 | const disconnect = jest.fn(); 24 | when(useConnectionEstablished).mockReturnValue({ 25 | providerUrl: 'https://pod.test', 26 | containerUrl: 'https://pod.test/alice/webclip', 27 | disconnect, 28 | }); 29 | render(); 30 | const disconnectButton = screen.getByText('Disconnect'); 31 | userEvent.click(disconnectButton); 32 | expect(disconnect).toHaveBeenCalled(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/options/HelpSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const HelpSection = () => { 4 | return ( 5 |
6 | 7 | 15 | 20 | 21 | 22 |

Need help?

23 |

24 | Do not hesitate to contact us, if you struggle with the setup process. 25 | 29 | {' '} 30 | Just post to our Q&A section on GitHub.{' '} 31 | 32 |

33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/solid-client-authn-chrome-ext/ChromeExtensionRedirector.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IIncomingRedirectHandler, 3 | IRedirector, 4 | } from '@inrupt/solid-client-authn-core'; 5 | import { launchWebAuthFlow } from './launchWebAuthFlow'; 6 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 7 | 8 | export type RedirectInfo = ISessionInfo & { fetch: typeof fetch }; 9 | 10 | export class ChromeExtensionRedirector implements IRedirector { 11 | constructor( 12 | private readonly redirectHandler: IIncomingRedirectHandler, 13 | private readonly afterRedirect: (info: RedirectInfo, error?: Error) => void 14 | ) {} 15 | 16 | redirect(redirectUrl: string): void { 17 | launchWebAuthFlow( 18 | { 19 | url: redirectUrl, 20 | interactive: true, 21 | }, 22 | async (redirectUrl, error) => { 23 | try { 24 | if (error) { 25 | throw error; 26 | } 27 | const sessionInfo = await this.redirectHandler.handle( 28 | redirectUrl, 29 | undefined 30 | ); 31 | this.afterRedirect(sessionInfo); 32 | } catch (error) { 33 | this.afterRedirect( 34 | { 35 | isLoggedIn: false, 36 | fetch: undefined, 37 | sessionId: '', 38 | }, 39 | error 40 | ); 41 | } 42 | } 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/options/authorization-section/CheckingAccessPermissions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ExtensionUrl } from '../../chrome/urls'; 3 | 4 | interface Props { 5 | extensionUrl: ExtensionUrl; 6 | } 7 | 8 | export const CheckingAccessPermissions = ({ extensionUrl }: Props) => ( 9 |
10 |
11 | 19 | 24 | 25 | Checking access permissions 26 |
27 |
28 |
29 | Extension Origin 30 |
31 | {extensionUrl.origin} 32 |
33 |
34 |
35 |
36 | ); 37 | -------------------------------------------------------------------------------- /src/options/optionsStorageApi.ts: -------------------------------------------------------------------------------- 1 | export interface Options { 2 | /** 3 | * URL of the chosen OIDC provider 4 | */ 5 | providerUrl: string; 6 | /** 7 | * whether WebClip is a trusted app with the required permissions in the connected pod 8 | */ 9 | trustedApp: boolean; 10 | /** 11 | * url of a container where the data is stored 12 | */ 13 | containerUrl: string; 14 | } 15 | 16 | type AreaName = 'sync' | 'local' | 'managed'; 17 | 18 | interface StorageChange { 19 | oldValue: string; 20 | newValue: string; 21 | } 22 | 23 | const defaultsOptions: Options = { 24 | providerUrl: '', 25 | trustedApp: false, 26 | containerUrl: '', 27 | }; 28 | 29 | export const save = (options: Options): Promise => { 30 | return new Promise((resolve) => { 31 | chrome.storage.sync.set(options, function () { 32 | console.log('saved options', options); 33 | resolve(options); 34 | }); 35 | }); 36 | }; 37 | 38 | export const load = (): Promise => { 39 | return new Promise((resolve) => { 40 | chrome.storage.sync.get(defaultsOptions, function (options) { 41 | console.log('loaded options', options); 42 | resolve(options as Options); 43 | }); 44 | }); 45 | }; 46 | 47 | export const onChanged = ( 48 | listener: ( 49 | changes: { [p: string]: StorageChange }, 50 | namespace: AreaName 51 | ) => void 52 | ) => chrome.storage.onChanged.addListener(listener); 53 | -------------------------------------------------------------------------------- /src/store/importToStore.ts: -------------------------------------------------------------------------------- 1 | import rdfDereferencer from 'rdf-dereference'; 2 | import { 3 | blankNode, 4 | IndexedFormula, 5 | isBlankNode, 6 | isLiteral, 7 | isNamedNode, 8 | lit, 9 | namedNode, 10 | sym, 11 | } from 'rdflib'; 12 | 13 | export async function importToStore(url: string, store: IndexedFormula) { 14 | const { data } = await rdfDereferencer.dereference(url); 15 | await new Promise((resolve, reject) => { 16 | data 17 | .on('data', (quad) => { 18 | // workarounds for incompatibility between rdflib.js and RDF/JS regarding toCanonical and toNT 19 | if (quad.object.datatype) { 20 | quad.object.datatype = sym(quad.object.datatype.value); 21 | } 22 | const subject = makeCompatibleToRdflib(quad.subject); 23 | const predicate = makeCompatibleToRdflib(quad.predicate); 24 | const object = makeCompatibleToRdflib(quad.object); 25 | store.add(subject, predicate, object, sym(url)); 26 | }) 27 | .on('error', (error) => reject(error)) 28 | .on('end', resolve); 29 | }); 30 | } 31 | 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | function makeCompatibleToRdflib(term: any) { 34 | return isNamedNode(term) 35 | ? namedNode(term.value) 36 | : isLiteral(term) 37 | ? lit(term.value, term.language, term.datatype) 38 | : isBlankNode(term) 39 | ? blankNode(term.value) 40 | : term; 41 | } 42 | -------------------------------------------------------------------------------- /src/store/StorageStore.ts: -------------------------------------------------------------------------------- 1 | import * as rdf from 'rdflib'; 2 | import { IndexedFormula, NamedNode, st, Statement, sym } from 'rdflib'; 3 | import solidNamespace from 'solid-namespace'; 4 | import urljoin from 'url-join'; 5 | import { Storage } from '../domain/Storage'; 6 | 7 | export class StorageStore { 8 | private store: IndexedFormula; 9 | private ns: Record NamedNode>; 10 | 11 | constructor(store: IndexedFormula) { 12 | this.store = store; 13 | this.ns = solidNamespace(rdf); 14 | } 15 | 16 | /** 17 | * Returns any storage linked from the given WebID, or null if none can be found. 18 | * @param webId 19 | */ 20 | getStorageForWebId(webId: string): Storage | null { 21 | const storage = this.store.any(sym(webId), this.ns.space('storage')); 22 | if (storage) { 23 | return new Storage(storage.value); 24 | } 25 | return null; 26 | } 27 | 28 | isContainer(containerUrl: string) { 29 | return this.store.holds( 30 | sym(containerUrl), 31 | this.ns.rdf('type'), 32 | this.ns.ldp('Container') 33 | ); 34 | } 35 | 36 | isStorage(url: string) { 37 | return this.store.holds( 38 | sym(url), 39 | this.ns.rdf('type'), 40 | this.ns.space('Storage') 41 | ); 42 | } 43 | 44 | createIndexDocumentStatement(containerUrl: string): Statement[] { 45 | const index = sym(urljoin(containerUrl, 'index.ttl')); 46 | return [st(index, this.ns.rdf('type'), this.ns.ldp('Resource'), index)]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/api/useSolidApis.spec.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react'; 2 | import { renderHook } from '@testing-library/react-hooks'; 3 | import { Session } from '../solid-client-authn-chrome-ext/Session'; 4 | import { useSolidApis } from './useSolidApis'; 5 | 6 | describe('useSolidApis', () => { 7 | it('re-creates new profile api after login', () => { 8 | let onLoginCallback: () => void; 9 | const session = { 10 | info: { 11 | webId: 'https://pod.test/alice#me', 12 | }, 13 | onLogin: (callback: () => void) => (onLoginCallback = callback), 14 | } as unknown as Session; 15 | 16 | const render = renderHook(() => useSolidApis(session)); 17 | 18 | const profileAPi = render.result.current.profileApi; 19 | expect(profileAPi).toBeDefined(); 20 | 21 | act(() => { 22 | onLoginCallback(); 23 | }); 24 | expect(render.result.current.profileApi).not.toBe(profileAPi); 25 | }); 26 | it('re-creates new storage api after login', () => { 27 | let onLoginCallback: () => void; 28 | const session = { 29 | info: { 30 | webId: 'https://pod.test/alice#me', 31 | }, 32 | onLogin: (callback: () => void) => (onLoginCallback = callback), 33 | } as unknown as Session; 34 | 35 | const render = renderHook(() => useSolidApis(session)); 36 | 37 | const storageApi = render.result.current.storageApi; 38 | expect(storageApi).toBeDefined(); 39 | 40 | act(() => { 41 | onLoginCallback(); 42 | }); 43 | expect(render.result.current.storageApi).not.toBe(storageApi); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/content/authorization-page/useAuthorization.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@inrupt/solid-client-authn-browser'; 2 | import { useEffect, useState } from 'react'; 3 | import { ExtensionUrl } from '../../chrome/urls'; 4 | import { MessageType } from '../../domain/messages'; 5 | import { useSolidApis } from '../../api/useSolidApis'; 6 | import { sendMessage } from '../../chrome/sendMessage'; 7 | 8 | export const useAuthorization = ( 9 | session: Session, 10 | providerUrl: string, 11 | extensionUrl: ExtensionUrl 12 | ) => { 13 | const [state, setState] = useState({ 14 | loading: true, 15 | success: false, 16 | error: null, 17 | }); 18 | 19 | const { profileApi } = useSolidApis(session); 20 | 21 | useEffect(() => { 22 | if (!session.info.isLoggedIn) { 23 | session 24 | .login({ 25 | oidcIssuer: providerUrl, 26 | }) 27 | .then(() => null); 28 | } else { 29 | profileApi 30 | .grantAccessTo(extensionUrl) 31 | .then(() => { 32 | setState((currentState) => ({ 33 | ...currentState, 34 | loading: false, 35 | success: true, 36 | error: null, 37 | })); 38 | }) 39 | .then(() => session.logout()) 40 | .then(() => sendMessage({ type: MessageType.ACCESS_GRANTED })) 41 | .catch((error) => { 42 | setState((currentState) => ({ 43 | ...currentState, 44 | loading: false, 45 | success: false, 46 | error, 47 | })); 48 | return session.logout(); 49 | }); 50 | } 51 | }, []); 52 | 53 | return state; 54 | }; 55 | -------------------------------------------------------------------------------- /src/options/useOptionsPage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer, useState } from 'react'; 2 | import { SolidSession } from '../api/SolidSession'; 3 | import { MessageHandler } from './messaging/MessageHandler'; 4 | 5 | import { load as loadOptions, save as saveOptions } from './optionsStorageApi'; 6 | 7 | import reducer, { ActionType, State } from './reducer'; 8 | import { useChromeExtension } from './useChromeExtension'; 9 | 10 | export const initialState: State = { 11 | loading: true, 12 | saved: false, 13 | unsavedChanges: false, 14 | sessionInfo: { 15 | sessionId: '', 16 | isLoggedIn: false, 17 | }, 18 | value: { providerUrl: '', trustedApp: false, containerUrl: '' }, 19 | }; 20 | 21 | export const useOptionsPage = (session: SolidSession) => { 22 | const [state, dispatch] = useReducer(reducer, { 23 | ...initialState, 24 | sessionInfo: session.info, 25 | }); 26 | 27 | const [messageHandler] = useState(new MessageHandler(dispatch)); 28 | 29 | const { redirectUrl, extensionUrl } = useChromeExtension(messageHandler); 30 | 31 | useEffect(() => { 32 | loadOptions().then((options) => { 33 | dispatch({ 34 | type: ActionType.OPTIONS_LOADED, 35 | payload: options, 36 | }); 37 | }); 38 | }, []); 39 | 40 | function save() { 41 | saveOptions(state.value).then(() => 42 | dispatch({ type: ActionType.OPTIONS_SAVED }) 43 | ); 44 | } 45 | 46 | useEffect(() => { 47 | if (state.unsavedChanges) { 48 | save(); 49 | } 50 | }, [state.value, state.unsavedChanges]); 51 | 52 | return { 53 | state, 54 | dispatch, 55 | redirectUrl, 56 | extensionUrl, 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /src/content/page-overlay/LoginButton.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | import { LoginButton } from './LoginButton'; 4 | import { useLogin } from './useLogin'; 5 | 6 | jest.mock('./useLogin'); 7 | 8 | describe('LoginButton', () => { 9 | it('shows the provider url', () => { 10 | const login = jest.fn(); 11 | (useLogin as jest.Mock).mockReturnValue({ 12 | login, 13 | }); 14 | render(); 15 | const provider = screen.queryByText('provider.test'); 16 | expect(provider).not.toBeNull(); 17 | }); 18 | 19 | it('triggers login when clicked', () => { 20 | const login = jest.fn(); 21 | (useLogin as jest.Mock).mockReturnValue({ 22 | login, 23 | }); 24 | render(); 25 | const button = screen.getByText('Login'); 26 | fireEvent.click(button); 27 | expect(login).toHaveBeenCalled(); 28 | }); 29 | 30 | it('shows error, when login failed', () => { 31 | (useLogin as jest.Mock).mockReturnValue({ 32 | error: new Error('something went wrong'), 33 | login: jest.fn(), 34 | }); 35 | render(); 36 | expect(screen.getByText('something went wrong')).toBeInTheDocument(); 37 | }); 38 | 39 | it('indicates loading', () => { 40 | (useLogin as jest.Mock).mockReturnValue({ 41 | error: null, 42 | loading: true, 43 | }); 44 | render(); 45 | expect(screen.getByText('Signing in')).toBeInTheDocument(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/options/choose-storage/ChooseStorage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from '../../components/Button'; 3 | import { Input } from '../../components/Input'; 4 | import { useChooseStorage } from './useChooseStorage'; 5 | 6 | export const ChooseStorage = () => { 7 | const { 8 | loading, 9 | submitting, 10 | manualChanges, 11 | containerUrl, 12 | setContainerUrl, 13 | submit, 14 | validationError, 15 | } = useChooseStorage(); 16 | return ( 17 |
18 |

19 | {loading 20 | ? "Let's find a storage location for your clips." 21 | : containerUrl === null 22 | ? 'WebClip could not find a storage associated with your Pod, please enter a URL manually.' 23 | : 'WebClip is going to store data at the following location. Confirm, if you are fine with that, or enter the URL of a different location.'} 24 |

25 | 38 | 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/content/page-overlay/useProfile.spec.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { when } from 'jest-when'; 3 | import { MessageType } from '../../domain/messages'; 4 | import { sendMessage } from '../../chrome/sendMessage'; 5 | import { useProfile } from './useProfile'; 6 | 7 | jest.mock('../../chrome/sendMessage'); 8 | 9 | describe('useProfile', () => { 10 | it('it returns a loading status while the profile is loading', () => { 11 | when(sendMessage) 12 | .calledWith({ 13 | type: MessageType.LOAD_PROFILE, 14 | }) 15 | .mockReturnValue(new Promise(() => null)); 16 | 17 | const { result } = renderHook(() => useProfile()); 18 | 19 | act(() => { 20 | expect(sendMessage).toHaveBeenCalledWith({ 21 | type: MessageType.LOAD_PROFILE, 22 | }); 23 | expect(result.all[0]).toEqual({ 24 | loading: true, 25 | }); 26 | expect(result.all).toHaveLength(1); 27 | }); 28 | }); 29 | 30 | it('returns the profile once it has been loaded', async () => { 31 | when(sendMessage) 32 | .calledWith({ 33 | type: MessageType.LOAD_PROFILE, 34 | }) 35 | .mockResolvedValue({ 36 | name: 'Jane Doe', 37 | }); 38 | 39 | const { result, waitForNextUpdate } = renderHook(() => useProfile()); 40 | 41 | await waitForNextUpdate(); 42 | 43 | act(() => { 44 | expect(result.all).toHaveLength(2); 45 | expect(result.all[0]).toEqual({ 46 | loading: true, 47 | }); 48 | expect(result.all[1]).toEqual({ 49 | loading: false, 50 | profile: { name: 'Jane Doe' }, 51 | }); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/content/page-overlay/useSessionInfo.spec.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { EventEmitter } from 'events'; 3 | import { act } from 'react-dom/test-utils'; 4 | import { MessageType } from '../../domain/messages'; 5 | import { useChromeMessageListener } from '../messaging/chromeMessageListenerContext'; 6 | import { useSessionInfo } from './useSessionInfo'; 7 | 8 | jest.mock('../messaging/chromeMessageListenerContext'); 9 | 10 | describe('useSessionInfo', () => { 11 | let chromeMessageListener: EventEmitter; 12 | 13 | beforeEach(() => { 14 | chromeMessageListener = new EventEmitter(); 15 | 16 | (useChromeMessageListener as jest.Mock).mockReturnValue( 17 | chromeMessageListener 18 | ); 19 | }); 20 | 21 | it('returns the initial session info by default', () => { 22 | const { result } = renderHook(() => 23 | useSessionInfo({ 24 | sessionId: 'SESSION_ID', 25 | isLoggedIn: false, 26 | }) 27 | ); 28 | 29 | expect(result.current).toEqual({ 30 | sessionId: 'SESSION_ID', 31 | isLoggedIn: false, 32 | }); 33 | }); 34 | 35 | it('updates the session info upon receipt of a chrome LOGGED_IN message', () => { 36 | const { result } = renderHook(() => 37 | useSessionInfo({ 38 | sessionId: 'SESSION_ID', 39 | isLoggedIn: false, 40 | }) 41 | ); 42 | 43 | const newSessionInfo = { 44 | sessionId: 'NEW_SESSION_ID', 45 | isLoggedIn: true, 46 | webId: 'WEB_ID', 47 | }; 48 | act(() => { 49 | chromeMessageListener.emit(MessageType.LOGGED_IN, newSessionInfo); 50 | }); 51 | 52 | expect(result.current).toEqual(newSessionInfo); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/options/connect-pod/ConnectPodSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Input } from '../../components/Input'; 3 | import { GetAPodSection } from '../get-a-pod/GetAPodSection'; 4 | import { ConnectPodButton } from './ConnectPodButton'; 5 | import { useConnectPod } from './useConnectPod'; 6 | 7 | export const ConnectPodSection = () => { 8 | const { setProviderUrl, providerUrl, onLogin } = useConnectPod(); 9 | 10 | return ( 11 |
12 |

Connect your Pod

13 |

14 | Please fill in the URL of your Solid pod provider, so that WebClip can 15 | find your pod. 16 |

17 | 18 | 35 |
36 | 37 |
38 | 39 | I do not have a Pod 40 | 41 | 42 |
43 |
44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/content/page-overlay/usePageData.spec.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { when } from 'jest-when'; 3 | import { MessageType } from '../../domain/messages'; 4 | 5 | import { sendMessage } from '../../chrome/sendMessage'; 6 | import { usePageData } from './usePageData'; 7 | 8 | jest.mock('../../chrome/sendMessage'); 9 | 10 | describe('usePageData', () => { 11 | it('is loading initially', () => { 12 | const { result } = renderHook(() => usePageData('https://page.example')); 13 | expect(result.all[0]).toMatchObject({ 14 | loading: true, 15 | }); 16 | }); 17 | 18 | it('sends message to data from page', async () => { 19 | when(sendMessage) 20 | .calledWith({ 21 | type: MessageType.IMPORT_PAGE_DATA, 22 | payload: { 23 | url: 'https://page.example', 24 | }, 25 | }) 26 | .mockResolvedValue(undefined); 27 | const { waitForNextUpdate } = renderHook(() => 28 | usePageData('https://page.example') 29 | ); 30 | await waitForNextUpdate(); 31 | expect(sendMessage).toHaveBeenCalledWith({ 32 | type: MessageType.IMPORT_PAGE_DATA, 33 | payload: { 34 | url: 'https://page.example', 35 | }, 36 | }); 37 | }); 38 | 39 | it('finished loading after import is done', async () => { 40 | when(sendMessage) 41 | .calledWith({ 42 | type: MessageType.IMPORT_PAGE_DATA, 43 | payload: { 44 | url: 'https://page.example', 45 | }, 46 | }) 47 | .mockResolvedValue(undefined); 48 | const { result, waitForNextUpdate } = renderHook(() => 49 | usePageData('https://page.example') 50 | ); 51 | await waitForNextUpdate(); 52 | expect(result.current).toMatchObject({ 53 | loading: false, 54 | }); 55 | expect(result.all.length).toBe(2); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/store/BookmarkStore.ts: -------------------------------------------------------------------------------- 1 | import * as rdf from 'rdflib'; 2 | import { graph, IndexedFormula, NamedNode, Statement } from 'rdflib'; 3 | import solidNamespace from 'solid-namespace'; 4 | import { createRelations } from './createRelations'; 5 | import { importToStore } from './importToStore'; 6 | 7 | export class BookmarkStore { 8 | private readonly graph: IndexedFormula; 9 | private ns: Record NamedNode>; 10 | 11 | constructor(store?: IndexedFormula) { 12 | this.graph = store ?? graph(); 13 | this.ns = solidNamespace(rdf); 14 | } 15 | 16 | /** 17 | * Import all data found at the given url to the store 18 | * @param url - The url to fetch and extract data from 19 | */ 20 | public importFromUrl(url: string) { 21 | return importToStore(url, this.graph); 22 | } 23 | 24 | /** 25 | * Copy all statements found in the source document to the target document and 26 | * create a http://schema.org/about link from source document to every subject found at that source 27 | * @param sourceDocument 28 | * @param targetDocument 29 | */ 30 | public createRelations( 31 | sourceDocument: NamedNode, 32 | targetDocument: NamedNode 33 | ): Statement[] { 34 | return createRelations(this.graph, sourceDocument, targetDocument); 35 | } 36 | 37 | /** 38 | * @deprecated 39 | */ 40 | getGraph() { 41 | return this.graph; 42 | } 43 | 44 | getIndexedBookmark(bookmarkedObject: NamedNode, index: NamedNode) { 45 | const matchingSubject = this.graph.any( 46 | null, 47 | this.ns.schema('object'), 48 | bookmarkedObject, 49 | index 50 | ); 51 | const isBookmark = this.graph.holds( 52 | matchingSubject, 53 | this.ns.rdf('type'), 54 | this.ns.schema('BookmarkAction'), 55 | index 56 | ); 57 | return isBookmark ? matchingSubject : null; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/test/turtleResponse.ts: -------------------------------------------------------------------------------- 1 | export function turtleResponse( 2 | bodyText: string, 3 | userPermissions = 'read write append control', 4 | publicPermissions = '' 5 | ) { 6 | return { 7 | ok: true, 8 | headers: new Headers({ 9 | 'Content-Type': 'text/turtle', 10 | 'wac-allow': `user="${userPermissions}", public="${publicPermissions}"`, 11 | 'accept-patch': 'application/sparql-update', 12 | }), 13 | status: 200, 14 | statusText: 'OK', 15 | text: async () => bodyText, 16 | }; 17 | } 18 | 19 | export function containerResponse( 20 | userPermissions = 'read write append control', 21 | publicPermissions = '' 22 | ) { 23 | return { 24 | ok: true, 25 | headers: new Headers({ 26 | 'Content-Type': 'text/turtle', 27 | 'wac-allow': `user="${userPermissions}", public="${publicPermissions}"`, 28 | 'accept-patch': 'application/sparql-update', 29 | Link: '; rel="type"', 30 | }), 31 | status: 200, 32 | statusText: 'OK', 33 | text: async () => ` 34 | @prefix ldp: . 35 | <> a ldp:Container, ldp:BasicContainer, ldp:Resource . 36 | `, 37 | }; 38 | } 39 | 40 | export function storageResponse( 41 | userPermissions = 'read write append control', 42 | publicPermissions = '' 43 | ) { 44 | return { 45 | ok: true, 46 | headers: new Headers({ 47 | 'Content-Type': 'text/turtle', 48 | 'wac-allow': `user="${userPermissions}", public="${publicPermissions}"`, 49 | 'accept-patch': 'application/sparql-update', 50 | Link: 'Link: ; rel="type"', 51 | }), 52 | status: 200, 53 | statusText: 'OK', 54 | text: async () => ` 55 | @prefix ldp: . 56 | <> a , ldp:Container, ldp:BasicContainer, ldp:Resource . 57 | `, 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/content/page-overlay/useBookmark.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Bookmark } from '../../domain/Bookmark'; 3 | import { MessageType } from '../../domain/messages'; 4 | import { PageMetaData } from '../../domain/PageMetaData'; 5 | import { sendMessage } from '../../chrome/sendMessage'; 6 | 7 | interface AsyncState { 8 | loading: boolean; 9 | saving: boolean; 10 | error: Error; 11 | result?: T; 12 | } 13 | 14 | export const useBookmark = (page: PageMetaData) => { 15 | const [{ loading, saving, error, result: bookmark }, setState] = useState< 16 | AsyncState 17 | >({ 18 | loading: true, 19 | saving: false, 20 | error: null, 21 | }); 22 | 23 | useEffect(() => { 24 | sendMessage({ 25 | type: MessageType.LOAD_BOOKMARK, 26 | payload: { page }, 27 | }) 28 | .then((bookmark: Bookmark) => { 29 | setState((state) => ({ 30 | ...state, 31 | loading: false, 32 | error: null, 33 | result: bookmark, 34 | })); 35 | }) 36 | .catch((error) => { 37 | setState((state) => ({ 38 | ...state, 39 | loading: false, 40 | error, 41 | })); 42 | }); 43 | }, []); 44 | 45 | return { 46 | loading, 47 | saving, 48 | error, 49 | bookmark, 50 | addBookmark: async () => { 51 | setState((state) => ({ ...state, saving: true })); 52 | try { 53 | const saved = (await sendMessage({ 54 | type: MessageType.ADD_BOOKMARK, 55 | payload: { 56 | page, 57 | bookmark, 58 | }, 59 | })) as Bookmark; 60 | setState((state) => ({ 61 | ...state, 62 | saving: false, 63 | error: null, 64 | result: saved, 65 | })); 66 | } catch (error) { 67 | setState((state) => ({ ...state, saving: false, error })); 68 | } 69 | }, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /src/options/messaging/MessageHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import { when } from 'jest-when'; 2 | import { closeTab } from '../../chrome/closeTab'; 3 | import { MessageType, Response } from '../../domain/messages'; 4 | import { ActionType, Dispatch } from '../reducer'; 5 | import { MessageHandler } from './MessageHandler'; 6 | 7 | jest.mock('../../chrome/closeTab'); 8 | 9 | describe('MessageHandler', () => { 10 | let dispatch: Dispatch; 11 | let result: Promise | boolean; 12 | let handler: MessageHandler; 13 | 14 | beforeEach(() => { 15 | dispatch = jest.fn(); 16 | handler = new MessageHandler(dispatch); 17 | }); 18 | 19 | describe('when message is not relevant', () => { 20 | beforeEach(() => { 21 | result = handler.handleMessage( 22 | { 23 | type: MessageType.LOGIN, 24 | }, 25 | { 26 | tab: { 27 | id: 123456, 28 | } as chrome.tabs.Tab, 29 | } 30 | ); 31 | }); 32 | 33 | it('returns false synchronously', () => { 34 | expect(result).toBe(false); 35 | }); 36 | }); 37 | 38 | describe('when ACCESS_GRANTED handled', () => { 39 | beforeEach(async () => { 40 | when(closeTab).mockReturnValue(null); 41 | const handler = new MessageHandler(dispatch); 42 | result = handler.handleMessage( 43 | { 44 | type: MessageType.ACCESS_GRANTED, 45 | }, 46 | { 47 | tab: { 48 | id: 123456, 49 | } as chrome.tabs.Tab, 50 | } 51 | ); 52 | }); 53 | 54 | it('it dispatches trusted app action', async () => { 55 | expect(dispatch).toHaveBeenCalledWith({ 56 | type: ActionType.TRUSTED_APP, 57 | }); 58 | }); 59 | 60 | it('it resolves to empty response', async () => { 61 | await expect(result).resolves.toEqual({}); 62 | }); 63 | 64 | it('it closes the sending tab', async () => { 65 | expect(closeTab).toHaveBeenCalledWith(123456); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/options/connection-established/ConnectionEstablished.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useConnectionEstablished } from './useConnectionEstablished'; 3 | 4 | export const ConnectionEstablished = () => { 5 | const { providerUrl, containerUrl, disconnect } = useConnectionEstablished(); 6 | 7 | return ( 8 |
9 |
10 | 18 | 23 | 24 | Everything is set up correctly. 25 |
26 | 27 |
28 |
29 | Your Pod Provider 30 |
31 | {providerUrl} 32 | 38 |
39 |
40 | {containerUrl && ( 41 |
42 | Data Location 43 |
44 | {containerUrl} 45 |
46 |
47 | )} 48 |
49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/store/createRelations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IndexedFormula, 3 | isBlankNode, 4 | NamedNode, 5 | st, 6 | Statement, 7 | sym, 8 | } from 'rdflib'; 9 | import { BlankNode } from 'rdflib/lib/tf-types'; 10 | import { createAboutStatements } from './createAboutStatements'; 11 | 12 | export function createRelations( 13 | store: IndexedFormula, 14 | pageUrl: NamedNode, 15 | targetDocument: NamedNode 16 | ): Statement[] { 17 | const related = mapPageStatementsToTargetDocument( 18 | store, 19 | pageUrl, 20 | targetDocument 21 | ); 22 | 23 | const about = createAboutStatements(pageUrl, related, targetDocument); 24 | return [...about, ...related]; 25 | } 26 | 27 | function hasType(store: IndexedFormula, it: Statement): boolean { 28 | const rdfType = store.anyValue( 29 | it.subject, 30 | sym('http://www.w3.org/1999/02/22-rdf-syntax-ns#type') 31 | ); 32 | const ogpType = store.anyValue(it.subject, sym('http://ogp.me/ns#type')); 33 | return !!(rdfType || ogpType); 34 | } 35 | 36 | function mapPageStatementsToTargetDocument( 37 | store: IndexedFormula, 38 | pageUrl: NamedNode, 39 | targetDocument: NamedNode 40 | ) { 41 | let count = 1; 42 | const ids: { [key: string]: NamedNode } = {}; 43 | 44 | function nodeNameFor(blankNode: BlankNode) { 45 | const value = blankNode.value; 46 | if (!ids[value]) { 47 | ids[value] = sym(targetDocument.uri + '#' + count++); 48 | } 49 | return ids[value]; 50 | } 51 | 52 | return store 53 | .statementsMatching(null, null, null, pageUrl) 54 | .map((it: Statement) => { 55 | const typed = hasType(store, it); 56 | 57 | if (!typed && it.subject.value !== pageUrl.value) return null; 58 | 59 | const subject = isBlankNode(it.subject) 60 | ? nodeNameFor(it.subject) 61 | : it.subject; 62 | 63 | const object = isBlankNode(it.object) 64 | ? nodeNameFor(it.object) 65 | : it.object; 66 | 67 | return st(subject, it.predicate, object, targetDocument); 68 | }) 69 | .filter((it) => it !== null); 70 | } 71 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); 4 | 5 | const config = { 6 | mode: 'development', 7 | devtool: 'cheap-module-source-map', 8 | entry: { 9 | content: path.join(__dirname, './dev/index.tsx'), 10 | options: path.join(__dirname, './dev/options.tsx'), 11 | }, 12 | output: { 13 | path: path.resolve(__dirname, './build'), 14 | filename: '[name].js', 15 | publicPath: '/', 16 | }, 17 | devServer: { 18 | hot: true, 19 | static: { 20 | directory: path.join(__dirname, 'dist'), 21 | }, 22 | }, 23 | resolve: { 24 | extensions: ['.ts', '.tsx', '.js', '*'], 25 | }, 26 | plugins: [ 27 | new NodePolyfillPlugin(), 28 | new HtmlWebpackPlugin({ 29 | title: 'WebClip (dev)', 30 | meta: { 31 | charset: 'utf-8', 32 | viewport: 'width=device-width, initial-scale=1, shrink-to-fit=no', 33 | 'theme-color': '#000000', 34 | }, 35 | chunks: ['content'], 36 | filename: 'index.html', 37 | template: './dev/index.html', 38 | hash: true, 39 | }), 40 | new HtmlWebpackPlugin({ 41 | title: 'Webclip', // change this to your app title 42 | meta: { 43 | charset: 'utf-8', 44 | viewport: 'width=device-width, initial-scale=1, shrink-to-fit=no', 45 | 'theme-color': '#000000', 46 | }, 47 | chunks: ['options'], 48 | filename: 'options.html', 49 | template: './dev/options.html', 50 | hash: true, 51 | }), 52 | ], 53 | module: { 54 | rules: [ 55 | { 56 | test: /\.js$/, 57 | exclude: /node_modules/, 58 | use: ['babel-loader'], 59 | }, 60 | { 61 | test: /\.tsx?$/, 62 | use: 'ts-loader', 63 | exclude: /node_modules/, 64 | }, 65 | { 66 | test: /\.css$/i, 67 | use: ['css-loader', 'postcss-loader'], 68 | }, 69 | ], 70 | }, 71 | }; 72 | module.exports = config; 73 | -------------------------------------------------------------------------------- /src/domain/messages.ts: -------------------------------------------------------------------------------- 1 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 2 | import { Bookmark } from './Bookmark'; 3 | import { PageMetaData } from './PageMetaData'; 4 | 5 | export enum MessageType { 6 | ACTIVATE = 'ACTIVATE', 7 | DEACTIVATE = 'DEACTIVATE', 8 | LOGIN = 'LOGIN', 9 | LOGOUT = 'LOGOUT', 10 | LOGGED_IN = 'LOGGED_IN', 11 | LOAD_PROFILE = 'LOAD_PROFILE', 12 | LOAD_BOOKMARK = 'LOAD_BOOKMARK', 13 | ADD_BOOKMARK = 'ADD_BOOKMARK', 14 | IMPORT_PAGE_DATA = 'IMPORT_PAGE_DATA', 15 | OPEN_OPTIONS = 'OPEN_OPTIONS', 16 | ACCESS_GRANTED = 'ACCESS_GRANTED', 17 | } 18 | 19 | export type Message = 20 | | ActivateMessage 21 | | OpenOptionsMessage 22 | | LoginMessage 23 | | LogoutMessage 24 | | LoggedInMessage 25 | | LoadProfileMessage 26 | | LoadBookmarkMessage 27 | | AddBookmarkMessage 28 | | ImportPageDataMessage 29 | | AccessGranted; 30 | 31 | export type ActivateMessage = { 32 | type: MessageType.ACTIVATE; 33 | payload: ISessionInfo & { providerUrl: string }; 34 | }; 35 | 36 | export type OpenOptionsMessage = { 37 | type: MessageType.OPEN_OPTIONS; 38 | }; 39 | 40 | export type LoginMessage = { 41 | type: MessageType.LOGIN; 42 | }; 43 | 44 | export type LogoutMessage = { 45 | type: MessageType.LOGOUT; 46 | }; 47 | 48 | export type LoggedInMessage = { 49 | type: MessageType.LOGGED_IN; 50 | payload: ISessionInfo; 51 | }; 52 | 53 | export type LoadProfileMessage = { 54 | type: MessageType.LOAD_PROFILE; 55 | }; 56 | 57 | export type LoadBookmarkMessage = { 58 | type: MessageType.LOAD_BOOKMARK; 59 | payload: { 60 | page: PageMetaData; 61 | }; 62 | }; 63 | 64 | export type AddBookmarkMessage = { 65 | type: MessageType.ADD_BOOKMARK; 66 | payload: { 67 | page: PageMetaData; 68 | bookmark?: Bookmark; 69 | }; 70 | }; 71 | 72 | export type ImportPageDataMessage = { 73 | type: MessageType.IMPORT_PAGE_DATA; 74 | payload: { 75 | url: string; 76 | }; 77 | }; 78 | 79 | export type AccessGranted = { 80 | type: MessageType.ACCESS_GRANTED; 81 | }; 82 | 83 | export type Response = { 84 | payload?: unknown; 85 | errorMessage?: string; 86 | }; 87 | -------------------------------------------------------------------------------- /src/options/connect-pod/useConnectPod.spec.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { when } from 'jest-when'; 3 | import { useOptions } from '../OptionsContext'; 4 | import { ActionType } from '../reducer'; 5 | import { initialState } from '../useOptionsPage'; 6 | import { useConnectPod } from './useConnectPod'; 7 | 8 | jest.mock('../OptionsContext'); 9 | 10 | describe('useConnectPod', () => { 11 | it('returns provider url', () => { 12 | when(useOptions).mockReturnValue({ 13 | state: { 14 | ...initialState, 15 | value: { 16 | ...initialState.value, 17 | providerUrl: 'https://provider.test', 18 | }, 19 | }, 20 | dispatch: () => null, 21 | }); 22 | const render = renderHook(() => useConnectPod()); 23 | expect(render.result.current).toMatchObject({ 24 | providerUrl: 'https://provider.test', 25 | }); 26 | }); 27 | 28 | it('dispatches SET_PROVIDER_URL', () => { 29 | const dispatch = jest.fn(); 30 | when(useOptions).mockReturnValue({ 31 | state: { 32 | ...initialState, 33 | }, 34 | dispatch, 35 | }); 36 | const render = renderHook(() => useConnectPod()); 37 | render.result.current.setProviderUrl('https://new.provider.test'); 38 | expect(dispatch).toHaveBeenCalledWith({ 39 | type: ActionType.SET_PROVIDER_URL, 40 | payload: 'https://new.provider.test', 41 | }); 42 | }); 43 | 44 | it('dispatches session info on login', () => { 45 | const dispatch = jest.fn(); 46 | when(useOptions).mockReturnValue({ 47 | state: { 48 | ...initialState, 49 | }, 50 | dispatch, 51 | }); 52 | const render = renderHook(() => useConnectPod()); 53 | render.result.current.onLogin({ 54 | isLoggedIn: true, 55 | webId: 'https://alice.test#me', 56 | sessionId: '1', 57 | }); 58 | 59 | expect(dispatch).toHaveBeenCalledWith({ 60 | type: ActionType.LOGGED_IN, 61 | payload: { 62 | isLoggedIn: true, 63 | webId: 'https://alice.test#me', 64 | sessionId: '1', 65 | }, 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const CopyPlugin = require('copy-webpack-plugin'); 4 | const baseManifest = require('./chrome/manifest.json'); 5 | const WebpackExtensionManifestPlugin = require('webpack-extension-manifest-plugin'); 6 | const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); 7 | 8 | const config = { 9 | mode: 'development', 10 | devtool: 'cheap-module-source-map', 11 | entry: { 12 | content: path.join(__dirname, './src/content.tsx'), 13 | background: path.join(__dirname, './src/background.ts'), 14 | options: path.join(__dirname, './src/options.tsx'), 15 | }, 16 | output: { 17 | path: path.resolve(__dirname, './build'), 18 | filename: '[name].js', 19 | publicPath: '/', 20 | }, 21 | resolve: { 22 | extensions: ['.ts', '.tsx', '.js', '*'], 23 | }, 24 | plugins: [ 25 | new NodePolyfillPlugin(), 26 | new HtmlWebpackPlugin({ 27 | title: 'Webclip', // change this to your app title 28 | meta: { 29 | charset: 'utf-8', 30 | viewport: 'width=device-width, initial-scale=1, shrink-to-fit=no', 31 | 'theme-color': '#000000', 32 | }, 33 | chunks: ['options'], 34 | filename: 'options.html', 35 | template: './src/assets/index.html', 36 | hash: true, 37 | }), 38 | new CopyPlugin({ 39 | patterns: [ 40 | { 41 | from: 'chrome/icons', 42 | to: 'icons', 43 | }, 44 | ], 45 | }), 46 | new WebpackExtensionManifestPlugin({ 47 | config: { 48 | base: baseManifest, 49 | }, 50 | pkgJsonProps: ['version'], 51 | }), 52 | ], 53 | module: { 54 | rules: [ 55 | { 56 | test: /\.js$/, 57 | exclude: /node_modules/, 58 | use: ['babel-loader'], 59 | }, 60 | { 61 | test: /\.tsx?$/, 62 | use: 'ts-loader', 63 | exclude: /node_modules/, 64 | }, 65 | { 66 | test: /\.css$/i, 67 | use: ['css-loader', 'postcss-loader'], 68 | }, 69 | ], 70 | }, 71 | }; 72 | module.exports = config; 73 | -------------------------------------------------------------------------------- /src/background/MessageHandler.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationApi } from '../api/AuthenticationApi'; 2 | import { BookmarkApi } from '../api/BookmarkApi'; 3 | import { SolidSession } from '../api/SolidSession'; 4 | import { Message, MessageType, Response } from '../domain/messages'; 5 | import { BookmarkStore } from '../store/BookmarkStore'; 6 | import { openOptionsPage } from './openOptionsPage'; 7 | import MessageSender = chrome.runtime.MessageSender; 8 | 9 | export class MessageHandler { 10 | constructor( 11 | private readonly session: SolidSession, 12 | private readonly bookmarkApi: BookmarkApi, 13 | private readonly store: BookmarkStore, 14 | private readonly authenticationApi: AuthenticationApi 15 | ) {} 16 | 17 | handleMessage( 18 | request: Message, 19 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 20 | sender: MessageSender 21 | ): boolean | Promise { 22 | switch (request.type) { 23 | case MessageType.OPEN_OPTIONS: 24 | openOptionsPage(); 25 | return Promise.resolve({}); 26 | case MessageType.LOGIN: 27 | return this.authenticationApi.login().then(() => ({})); 28 | case MessageType.LOGOUT: 29 | return this.authenticationApi.logout().then(() => ({})); 30 | case MessageType.LOAD_PROFILE: { 31 | return this.bookmarkApi.loadProfile().then((profile) => ({ 32 | payload: profile, 33 | })); 34 | } 35 | case MessageType.LOAD_BOOKMARK: { 36 | return this.bookmarkApi 37 | .loadBookmark(request.payload.page) 38 | .then((bookmark) => ({ 39 | payload: bookmark, 40 | })); 41 | } 42 | case MessageType.ADD_BOOKMARK: { 43 | return this.bookmarkApi 44 | .bookmark(request.payload.page, request.payload.bookmark) 45 | .then((result) => ({ 46 | payload: result, 47 | })); 48 | } 49 | case MessageType.IMPORT_PAGE_DATA: { 50 | return this.store.importFromUrl(request.payload.url).then(() => ({})); 51 | } 52 | default: { 53 | return false; 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /dev/options.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | getDefaultSession, 3 | handleIncomingRedirect, 4 | Session, 5 | } from '@inrupt/solid-client-authn-browser'; 6 | import React from 'react'; 7 | import ReactDOM from 'react-dom'; 8 | 9 | import 'style-loader!../src/assets/content.css'; 10 | import { OptionsPage } from '../src/options/OptionsPage'; 11 | import { Options } from '../src/options/optionsStorageApi'; 12 | import { Session as ChromeExtensionSession } from '../src/solid-client-authn-chrome-ext/Session'; 13 | 14 | let inMemoryStorage = { 15 | providerUrl: 'http://localhost:3000', 16 | trustedApp: true, 17 | containerUrl: '', 18 | }; 19 | 20 | chrome.runtime = { 21 | id: 'fake-extension-id', 22 | onMessage: { 23 | addListener: () => { 24 | return; 25 | }, 26 | }, 27 | } as unknown as typeof chrome.runtime; 28 | 29 | chrome.storage = { 30 | sync: { 31 | get: (defaultOptions: Options, callback: (options: Options) => void) => { 32 | callback(inMemoryStorage); 33 | }, 34 | set: (options: Options, callback: (options: Options) => void) => { 35 | inMemoryStorage = { 36 | ...inMemoryStorage, 37 | ...options, 38 | }; 39 | callback(inMemoryStorage); 40 | }, 41 | }, 42 | } as unknown as typeof chrome.storage; 43 | 44 | chrome.identity = { 45 | getRedirectURL: () => window.location.href, 46 | } as unknown as typeof chrome.identity; 47 | 48 | chrome.extension = { 49 | getURL: (path: string) => `chrome-extension://fake-extension-id${path}`, 50 | } as unknown as typeof chrome.extension; 51 | 52 | const root = document.getElementById('root'); 53 | 54 | async function handleRedirectAfterLogin() { 55 | await handleIncomingRedirect(); 56 | return getDefaultSession(); 57 | } 58 | 59 | handleRedirectAfterLogin().then((session: Session) => { 60 | console.log({ session }); 61 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 62 | // @ts-ignore: this dev environment is no chrome extension, so we use regular browser session here 63 | renderApp(session); 64 | }); 65 | 66 | function renderApp(session: ChromeExtensionSession) { 67 | ReactDOM.render(, root); 68 | } 69 | -------------------------------------------------------------------------------- /src/store/ProfileStore.ts: -------------------------------------------------------------------------------- 1 | import { IndexedFormula, sym, Namespace } from 'rdflib'; 2 | 3 | const acl = Namespace('http://www.w3.org/ns/auth/acl#'); 4 | 5 | /** 6 | * User profile related queries to a rdflib store 7 | */ 8 | export class ProfileStore { 9 | private store: IndexedFormula; 10 | 11 | constructor(store: IndexedFormula) { 12 | this.store = store; 13 | } 14 | 15 | /** 16 | * Checks whether the profile document of the given WebID 17 | * holds any statement pointing to the given origin 18 | * 19 | * @param webId The Web ID of the user, whose profile document is checked 20 | * @param origin The origin that is looked for 21 | */ 22 | holdsOrigin(webId: string, origin: string) { 23 | const profileDoc = sym(webId).doc(); 24 | return this.store.holds(null, acl('origin'), sym(origin), profileDoc); 25 | } 26 | 27 | /** 28 | * Checks whether the user identified by the given Web ID has granted all the 29 | * required permissions to the WebClip extension identified by the given origin 30 | * 31 | * @param webId The Web ID of the user, whose profile document is checked 32 | * @param extensionOrigin The origin of the extension whose permissions are checked 33 | */ 34 | checkAccessPermissions(webId: string, extensionOrigin: string) { 35 | const profileDoc = sym(webId).doc(); 36 | const trustedApp = this.store.anyStatementMatching( 37 | null, 38 | acl('origin'), 39 | sym(extensionOrigin), 40 | profileDoc 41 | )?.subject; 42 | if (!trustedApp) return false; 43 | const isTrusted: boolean = this.store.holds( 44 | sym(webId), 45 | acl('trustedApp'), 46 | trustedApp 47 | ); 48 | const canRead = this.store.holds( 49 | trustedApp, 50 | acl('mode'), 51 | acl('Read'), 52 | profileDoc 53 | ); 54 | const canWrite = this.store.holds( 55 | trustedApp, 56 | acl('mode'), 57 | acl('Write'), 58 | profileDoc 59 | ); 60 | const canAppend = this.store.holds( 61 | trustedApp, 62 | acl('mode'), 63 | acl('Append'), 64 | profileDoc 65 | ); 66 | return isTrusted && canRead && canWrite && canAppend; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/content/page-overlay/useLogin.spec.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { when } from 'jest-when'; 3 | import { MessageType } from '../../domain/messages'; 4 | import { sendMessage } from '../../chrome/sendMessage'; 5 | import { useLogin } from './useLogin'; 6 | 7 | jest.mock('../../chrome/sendMessage'); 8 | 9 | describe('useLogin', () => { 10 | it('returns loading false', () => { 11 | const { result } = renderHook(() => useLogin()); 12 | expect(result.current).toMatchObject({ 13 | loading: false, 14 | }); 15 | }); 16 | 17 | it('sends message to the background script on login', async () => { 18 | const { result } = renderHook(() => useLogin()); 19 | await act(async () => { 20 | await result.current.login(); 21 | }); 22 | expect(sendMessage).toHaveBeenCalledWith({ type: MessageType.LOGIN }); 23 | }); 24 | 25 | it('indicates loading and no error while logging in', async () => { 26 | const { result } = renderHook(() => useLogin()); 27 | await act(async () => { 28 | await result.current.login(); 29 | }); 30 | expect(result.all).toHaveLength(3); 31 | expect(result.all[0]).toMatchObject({ 32 | loading: false, 33 | error: null, 34 | }); 35 | expect(result.all[1]).toMatchObject({ 36 | loading: true, 37 | error: null, 38 | }); 39 | expect(result.all[2]).toMatchObject({ 40 | loading: false, 41 | error: null, 42 | }); 43 | }); 44 | 45 | it('returns error and stops loading, when login failed', async () => { 46 | when(sendMessage) 47 | .calledWith({ type: MessageType.LOGIN }) 48 | .mockRejectedValue(new Error('something went wrong')); 49 | const { result } = renderHook(() => useLogin()); 50 | await act(async () => { 51 | await result.current.login(); 52 | }); 53 | expect(result.all[0]).toMatchObject({ 54 | loading: false, 55 | error: null, 56 | }); 57 | expect(result.all[1]).toMatchObject({ 58 | loading: true, 59 | error: null, 60 | }); 61 | expect(result.all[2]).toMatchObject({ 62 | loading: false, 63 | error: new Error('something went wrong'), 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/content/page-overlay/ToolbarContainer.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | import { ToolbarContainer } from './ToolbarContainer'; 4 | import { useBookmark } from './useBookmark'; 5 | import { usePage } from './usePage'; 6 | import { usePageData } from './usePageData'; 7 | import { useProfile } from './useProfile'; 8 | 9 | jest.mock('./useProfile'); 10 | jest.mock('./useBookmark'); 11 | jest.mock('./usePageData'); 12 | jest.mock('./usePage'); 13 | 14 | describe('ToolbarContainer', () => { 15 | const { location } = window; 16 | let addBookmark: jest.Mock; 17 | 18 | beforeEach(() => { 19 | delete window.location; 20 | window.location = { ...location }; 21 | window.location.href = ''; 22 | window.document.title = ''; 23 | 24 | (useProfile as jest.Mock).mockReturnValue({ 25 | loading: false, 26 | profile: { name: 'Jane Doe' }, 27 | }); 28 | 29 | (usePage as jest.Mock).mockReturnValue({}); 30 | 31 | (usePageData as jest.Mock).mockReturnValue({ 32 | loading: false, 33 | }); 34 | 35 | addBookmark = jest.fn(); 36 | (useBookmark as jest.Mock).mockReturnValue({ 37 | loading: false, 38 | addBookmark, 39 | }); 40 | }); 41 | 42 | afterEach(() => { 43 | window.location = location; 44 | }); 45 | 46 | it('renders loading indicator while profile is loading', () => { 47 | (useProfile as jest.Mock).mockReturnValue({ 48 | loading: true, 49 | profile: null, 50 | }); 51 | render(); 52 | expect(screen.getByText('Loading...')).toBeInTheDocument(); 53 | }); 54 | 55 | it('renders loading indicator while page data is loading', () => { 56 | (usePage as jest.Mock).mockReturnValue({ 57 | url: 'https://page.example/', 58 | }); 59 | (usePageData as jest.Mock).mockReturnValue({ 60 | loading: true, 61 | }); 62 | render(); 63 | expect(screen.getByText('Loading...')).toBeInTheDocument(); 64 | expect(usePageData).toHaveBeenCalledWith('https://page.example/'); 65 | }); 66 | 67 | it("renders the user's name", () => { 68 | render(); 69 | expect(screen.getByText('Jane Doe')).toBeInTheDocument(); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/content.tsx: -------------------------------------------------------------------------------- 1 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | import contentCss from './assets/content.css'; 6 | import { getExtensionUrl } from './chrome/urls'; 7 | import { renderAuthorizationPage } from './content/authorization-page'; 8 | import { isOnAuthorizationPage } from './content/authorization-page/isOnAuthorizationPage'; 9 | import { ChromeMessageListener } from './content/messaging/ChromeMessageListener'; 10 | import { WebClip } from './content/page-overlay/WebClip'; 11 | import { MessageType } from './domain/messages'; 12 | 13 | const root = document.createElement('div'); 14 | root.id = 'webclip'; 15 | document.body.appendChild(root); 16 | const shadowRoot = root.attachShadow({ mode: 'open' }); 17 | const container = document.createElement('div'); 18 | const styleTag = document.createElement('style'); 19 | styleTag.innerHTML = contentCss; 20 | shadowRoot.appendChild(styleTag); 21 | shadowRoot.appendChild(container); 22 | 23 | const extensionId = chrome.runtime.id; 24 | const extensionUrl = getExtensionUrl(); 25 | 26 | if (isOnAuthorizationPage(extensionId)) { 27 | renderAuthorizationPage(extensionUrl, root, container); 28 | } else { 29 | const chromeMessageListener = new ChromeMessageListener(); 30 | 31 | chrome.runtime.onMessage.addListener(function ( 32 | request, 33 | sender, 34 | sendResponse 35 | ) { 36 | switch (request.type) { 37 | case MessageType.ACTIVATE: { 38 | const { providerUrl, ...sessionInfo } = request.payload; 39 | renderApp(chromeMessageListener, sessionInfo, providerUrl); 40 | break; 41 | } 42 | case MessageType.DEACTIVATE: { 43 | unmountApp(); 44 | break; 45 | } 46 | } 47 | sendResponse(); 48 | }); 49 | } 50 | 51 | function renderApp( 52 | chromeMessageListener: ChromeMessageListener, 53 | sessionInfo: ISessionInfo, 54 | providerUrl: string 55 | ) { 56 | ReactDOM.render( 57 | , 63 | container 64 | ); 65 | } 66 | 67 | function unmountApp() { 68 | ReactDOM.unmountComponentAtNode(container); 69 | } 70 | -------------------------------------------------------------------------------- /src/content/authorization-page/AuthorizationPage.tsx: -------------------------------------------------------------------------------- 1 | import { Session } from '@inrupt/solid-client-authn-browser'; 2 | import React from 'react'; 3 | import { ExtensionUrl } from '../../chrome/urls'; 4 | import { ErrorDetails } from './ErrorDetails'; 5 | import { useAuthorization } from './useAuthorization'; 6 | 7 | interface Props { 8 | session: Session; 9 | providerUrl: string; 10 | extensionUrl: ExtensionUrl; 11 | } 12 | 13 | export const AuthorizationPage = ({ 14 | session, 15 | providerUrl, 16 | extensionUrl, 17 | }: Props) => { 18 | const { loading, success, error } = useAuthorization( 19 | session, 20 | providerUrl, 21 | extensionUrl 22 | ); 23 | 24 | return ( 25 |
26 | {loading ? ( 27 | 28 | ) : success ? ( 29 | 30 | ) : ( 31 | 32 | )} 33 |
34 | ); 35 | }; 36 | 37 | const SuccessMessage = () => ( 38 |
39 | 45 | 50 | 51 |

All done! You can now close this window and start using WebClip.

52 |
53 | ); 54 | 55 | const LoadingMessage = () => ( 56 |
57 | 63 | 68 | 69 | Please wait, while WebClip is being authorized. 70 |
71 | ); 72 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import { 2 | activateWebClipForTab, 3 | deactivateWebClipForTab, 4 | } from './background/activate'; 5 | import { createMessageHandler } from './background/createMessageHandler'; 6 | import { MessageHandler } from './background/MessageHandler'; 7 | import { sendMessageToActiveTab } from './background/sendMessageToActiveTab'; 8 | import { MessageType, Response } from './domain/messages'; 9 | import { OptionsStorage } from './options/OptionsStorage'; 10 | import { Session } from './solid-client-authn-chrome-ext/Session'; 11 | 12 | const session = new Session(); 13 | 14 | let messageHandler: MessageHandler = null; 15 | 16 | const optionsStorage = new OptionsStorage(); 17 | 18 | optionsStorage.init().then(() => { 19 | optionsStorage.on('providerUrl', async () => { 20 | console.log('logging out, because provider URL changed'); 21 | await session.logout(); 22 | }); 23 | 24 | messageHandler = createMessageHandler(session, optionsStorage); 25 | 26 | chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) { 27 | if (changeInfo.status == 'complete') { 28 | deactivateWebClipForTab(tab); 29 | } 30 | }); 31 | 32 | chrome.browserAction.onClicked.addListener(async function (tab) { 33 | const { providerUrl } = optionsStorage.getOptions(); 34 | if (session.isExpired()) { 35 | console.log('session expired, logging out'); 36 | await session.logout(); 37 | } 38 | activateWebClipForTab(tab, session.info, providerUrl); 39 | }); 40 | 41 | chrome.runtime.onMessage.addListener(function ( 42 | request, 43 | sender, 44 | sendResponse: (response: Response) => void 45 | ) { 46 | const result = messageHandler.handleMessage(request, sender); 47 | if (result instanceof Promise) { 48 | result 49 | .then(sendResponse) 50 | .catch((error) => sendResponse({ errorMessage: error.toString() })); 51 | return true; // indicate async response 52 | } else { 53 | return result; 54 | } 55 | }); 56 | 57 | session.onLogin(() => { 58 | messageHandler = createMessageHandler(session, optionsStorage); 59 | 60 | sendMessageToActiveTab({ 61 | type: MessageType.LOGGED_IN, 62 | payload: session.info, 63 | }); 64 | }); 65 | 66 | session.onLogout(() => { 67 | messageHandler = createMessageHandler(session, optionsStorage); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/options/choose-storage/useChooseStorage.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import urljoin from 'url-join'; 3 | import { useStorageApi } from '../../api/ApiContext'; 4 | import { useOptions } from '../OptionsContext'; 5 | import { ActionType } from '../reducer'; 6 | 7 | interface State { 8 | loading: boolean; 9 | submitting: boolean; 10 | containerUrl: string; 11 | manualChanges: boolean; 12 | validationError?: Error; 13 | } 14 | 15 | function addMissingSlash(containerUrl: string) { 16 | return urljoin(containerUrl, '/'); 17 | } 18 | 19 | export function useChooseStorage() { 20 | const { dispatch } = useOptions(); 21 | const [state, setState] = useState({ 22 | loading: true, 23 | submitting: false, 24 | containerUrl: '', 25 | manualChanges: false, 26 | }); 27 | const storageApi = useStorageApi(); 28 | useEffect(() => { 29 | storageApi.findStorage().then((storage) => { 30 | setState((state) => ({ 31 | ...state, 32 | loading: false, 33 | manualChanges: storage === null, 34 | containerUrl: storage 35 | ? new URL('webclip/', storage.url).toString() 36 | : null, 37 | })); 38 | }); 39 | }, [storageApi]); 40 | 41 | const setContainerUrl = useCallback( 42 | (containerUrl: string) => 43 | setState((state) => ({ 44 | ...state, 45 | manualChanges: true, 46 | validationError: null, 47 | containerUrl, 48 | })), 49 | [] 50 | ); 51 | 52 | const submit = useCallback(async () => { 53 | const containerUrl = addMissingSlash(state.containerUrl); 54 | setState((state) => ({ 55 | ...state, 56 | containerUrl, 57 | submitting: true, 58 | })); 59 | const result = await storageApi.ensureValidContainer(containerUrl); 60 | setState((state) => ({ 61 | ...state, 62 | submitting: false, 63 | validationError: 64 | result === false 65 | ? new Error( 66 | 'Please provide the URL of an existing, accessible container' 67 | ) 68 | : null, 69 | })); 70 | if (result === true) { 71 | dispatch({ 72 | type: ActionType.SELECTED_STORAGE_CONTAINER, 73 | payload: containerUrl, 74 | }); 75 | } 76 | }, [storageApi, state.containerUrl]); 77 | 78 | return { 79 | ...state, 80 | setContainerUrl, 81 | submit, 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/chrome/sendMessage.spec.ts: -------------------------------------------------------------------------------- 1 | import { when } from 'jest-when'; 2 | import { MessageType, Response } from '../domain/messages'; 3 | import { sendMessage } from './sendMessage'; 4 | 5 | describe('messages', () => { 6 | describe('sendMessage', () => { 7 | it('sends message via chrome runtime', async () => { 8 | sendMessage({ type: MessageType.LOGIN }).then(); 9 | expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( 10 | { type: MessageType.LOGIN }, 11 | expect.any(Function) 12 | ); 13 | }); 14 | it('promise rejects if response is null', async () => { 15 | when(chrome.runtime.sendMessage).mockImplementation( 16 | (message: unknown, callback?: (response: Response) => void) => { 17 | callback(null); 18 | } 19 | ); 20 | const promise = sendMessage({ type: MessageType.LOGIN }); 21 | await expect(promise).rejects.toEqual( 22 | new Error('response to LOGIN message was null') 23 | ); 24 | }); 25 | it('promise resolves to callback response', async () => { 26 | when(chrome.runtime.sendMessage).mockImplementation( 27 | (message: unknown, callback?: (response: Response) => void) => { 28 | callback({ 29 | payload: 'response data', 30 | }); 31 | } 32 | ); 33 | const result = await sendMessage({ type: MessageType.LOGIN }); 34 | expect(result).toEqual('response data'); 35 | }); 36 | it('promise rejects in case of a runtime error', async () => { 37 | when(chrome.runtime.sendMessage).mockImplementation( 38 | (message: unknown, callback?: (response: Response) => void) => { 39 | chrome.runtime.lastError = new Error('something went wrong'); 40 | callback(undefined); 41 | } 42 | ); 43 | const promise = sendMessage({ type: MessageType.LOGIN }); 44 | await expect(promise).rejects.toEqual(new Error('something went wrong')); 45 | }); 46 | it('promise rejects in case of an error response', async () => { 47 | when(chrome.runtime.sendMessage).mockImplementation( 48 | (message: unknown, callback?: (response: Response) => void) => { 49 | callback({ errorMessage: 'something went wrong' }); 50 | } 51 | ); 52 | const promise = sendMessage({ type: MessageType.LOGIN }); 53 | await expect(promise).rejects.toEqual(new Error('something went wrong')); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [ 16.x ] 15 | outputs: 16 | version: ${{ steps.extract_version.outputs.version }} 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - run: npm ci 25 | - run: npm run lint 26 | - run: npm run dependencies:check 27 | - run: npm test 28 | - run: npm run build 29 | 30 | - name: Extract version 31 | id: extract_version 32 | uses: Saionaro/extract-package-version@v1.0.6 33 | 34 | - name: Create archive 35 | uses: thedoctor0/zip-release@master 36 | with: 37 | type: 'zip' 38 | filename: webclip-${{ steps.extract_version.outputs.version }}.zip 39 | directory: 'build' 40 | 41 | - name: Save zip 42 | uses: actions/upload-artifact@v2 43 | with: 44 | name: zip 45 | path: build/webclip-${{ steps.extract_version.outputs.version }}.zip 46 | retention-days: 30 47 | 48 | pre-release: 49 | if: github.ref == 'refs/heads/main' 50 | needs: build 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/download-artifact@v2 54 | with: 55 | name: zip 56 | 57 | - name: Create pre-release 58 | uses: ncipollo/release-action@v1 59 | with: 60 | tag: ${{ needs.build.outputs.version }}-dev 61 | commit: ${{ github.sha }} 62 | prerelease: true 63 | allowUpdates: true 64 | artifacts: "*.zip" 65 | token: ${{ secrets.GITHUB_TOKEN }} 66 | 67 | release: 68 | needs: 69 | - build 70 | - pre-release 71 | environment: prod 72 | runs-on: ubuntu-latest 73 | steps: 74 | - uses: actions/download-artifact@v2 75 | with: 76 | name: zip 77 | 78 | - name: Create draft release 79 | uses: ncipollo/release-action@v1 80 | with: 81 | tag: ${{ needs.build.outputs.version }} 82 | commit: ${{ github.sha }} 83 | allowUpdates: false 84 | artifacts: "*.zip" 85 | token: ${{ secrets.GITHUB_TOKEN }} 86 | -------------------------------------------------------------------------------- /src/api/ProfileApi.ts: -------------------------------------------------------------------------------- 1 | import { Fetcher, LiveStore, Namespace, st, sym, UpdateManager } from 'rdflib'; 2 | import { ExtensionUrl } from '../chrome/urls'; 3 | import { ProfileStore } from '../store/ProfileStore'; 4 | import { SolidSession } from './SolidSession'; 5 | 6 | const acl = Namespace('http://www.w3.org/ns/auth/acl#'); 7 | 8 | /** 9 | * User profile related API calls to the Solid Pod 10 | */ 11 | export class ProfileApi { 12 | private store: ProfileStore; 13 | private fetcher: Fetcher; 14 | private updater: UpdateManager; 15 | 16 | constructor(private session: SolidSession, liveStore: LiveStore) { 17 | this.store = new ProfileStore(liveStore); 18 | this.fetcher = liveStore.fetcher; 19 | this.updater = liveStore.updater; 20 | } 21 | 22 | /** 23 | * Determines whether the currently authenticated user either 24 | * 1) has configured the required access permissions to the given extension in their 25 | * profile document 26 | * or 27 | * 2) uses a Pod server that does not use origin based authorization 28 | * 29 | * Returns false if none of this is the case, true otherwise 30 | * 31 | * @param extensionUrl The actual url of the extension 32 | * @param redirectUrl The pseudo-redirect url of the extension 33 | */ 34 | async canExtensionAccessPod(extensionUrl: ExtensionUrl, redirectUrl: URL) { 35 | const webId = this.session.info.webId; 36 | await this.fetcher.load(webId); 37 | if (!this.store.holdsOrigin(webId, redirectUrl.origin)) { 38 | // assume the pod provider does NOT use origin based authorization 39 | return true; 40 | } 41 | return this.store.checkAccessPermissions(webId, extensionUrl.origin); 42 | } 43 | 44 | async grantAccessTo(extensionUrl: ExtensionUrl) { 45 | const webId = this.session.info.webId; 46 | const me = sym(webId); 47 | const profileDoc = me.doc(); 48 | const trustedApp = sym(profileDoc.uri + '#trust-webclip'); 49 | const trustStatements = [ 50 | st(me, acl('trustedApp'), trustedApp, profileDoc), 51 | st(trustedApp, acl('origin'), sym(extensionUrl.origin), profileDoc), 52 | st(trustedApp, acl('mode'), acl('Read'), profileDoc), 53 | st(trustedApp, acl('mode'), acl('Write'), profileDoc), 54 | st(trustedApp, acl('mode'), acl('Append'), profileDoc), 55 | ]; 56 | return this.updater.update([], trustStatements); 57 | } 58 | 59 | getProfileDocUrl() { 60 | return sym(this.session.info.webId).doc().uri; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/solid-client-authn-chrome-ext/Session.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ILoginInputOptions, 3 | ISessionInfo, 4 | } from '@inrupt/solid-client-authn-browser'; 5 | import ClientAuthentication from '@inrupt/solid-client-authn-browser/dist/ClientAuthentication'; 6 | import { EventEmitter } from 'events'; 7 | import { v4 as uuid } from 'uuid'; 8 | import { RedirectInfo } from './ChromeExtensionRedirector'; 9 | import { getClientAuthentication } from './getClientAuthentication'; 10 | import { now } from './time'; 11 | 12 | export class Session extends EventEmitter { 13 | private clientAuthentication: ClientAuthentication; 14 | public info: ISessionInfo = { 15 | sessionId: uuid(), 16 | isLoggedIn: false, 17 | }; 18 | public fetch: typeof fetch = window.fetch.bind(window); 19 | 20 | private resolveLogin: (value: PromiseLike | void) => void; 21 | private rejectLogin: (reason?: Error) => void; 22 | 23 | constructor() { 24 | super(); 25 | this.clientAuthentication = getClientAuthentication( 26 | (redirectInfo: RedirectInfo, error?: Error) => { 27 | const { fetch, ...info } = redirectInfo; 28 | this.info = info; 29 | if (error) { 30 | this.rejectLogin(error); 31 | } else { 32 | this.resolveLogin(); 33 | } 34 | if (info.isLoggedIn) { 35 | this.fetch = fetch.bind(window); 36 | this.emit('login'); 37 | } 38 | } 39 | ); 40 | } 41 | 42 | login = async (options: ILoginInputOptions) => { 43 | return new Promise((resolve, reject) => { 44 | this.resolveLogin = resolve; 45 | this.rejectLogin = reject; 46 | this.clientAuthentication 47 | .login( 48 | { 49 | sessionId: this.info.sessionId, 50 | ...options, 51 | tokenType: options.tokenType ?? 'DPoP', 52 | }, 53 | this 54 | ) 55 | .catch((err) => reject(err)); 56 | }); 57 | }; 58 | 59 | logout = async () => { 60 | await this.clientAuthentication.logout(this.info.sessionId); 61 | this.info.isLoggedIn = false; 62 | this.fetch = this.clientAuthentication.fetch; 63 | this.emit('logout'); 64 | }; 65 | 66 | onLogin(callback: () => unknown) { 67 | this.on('login', callback); 68 | } 69 | 70 | onLogout(callback: () => unknown) { 71 | this.on('logout', callback); 72 | } 73 | 74 | isExpired() { 75 | if (this.info.isLoggedIn && this.info.expirationDate) { 76 | return this.info.expirationDate <= now(); 77 | } 78 | return false; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/api/AuthenticationApi.spec.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@inrupt/solid-client-authn-browser'; 2 | import { OptionsStorage } from '../options/OptionsStorage'; 3 | import { AuthenticationApi } from './AuthenticationApi'; 4 | 5 | describe('AuthenticationApi', () => { 6 | describe('login', () => { 7 | it('logs in against the configured provider url', async () => { 8 | // given a provider url has been configured 9 | const optionsStorage = { 10 | getOptions: jest.fn().mockReturnValue({ 11 | providerUrl: 'https://pod.provider.example', 12 | }), 13 | } as unknown as OptionsStorage; 14 | const session = { 15 | info: { isLoggedIn: false }, 16 | login: jest.fn(), 17 | } as unknown as Session; 18 | // when I log in 19 | const api = new AuthenticationApi(session, optionsStorage); 20 | await api.login(); 21 | // then I can log in at that pod provider and am redirected to the current page after that 22 | expect(session.login).toHaveBeenCalledWith({ 23 | oidcIssuer: 'https://pod.provider.example', 24 | redirectUrl: 'http://localhost/', 25 | }); 26 | }); 27 | 28 | it('login fails if provider url is not present yet', async () => { 29 | // given no provider url has been configured 30 | const optionsStorage = { 31 | getOptions: jest.fn().mockReturnValue({ 32 | providerUrl: undefined, 33 | }), 34 | } as unknown as OptionsStorage; 35 | // when I try to log in 36 | const api = new AuthenticationApi( 37 | { info: { isLoggedIn: false } } as Session, 38 | optionsStorage 39 | ); 40 | // then I see this error 41 | await expect(() => api.login()).rejects.toEqual( 42 | new Error('No pod provider URL configured') 43 | ); 44 | }); 45 | }); 46 | describe('logout', () => { 47 | it('logs out from current session', async () => { 48 | // given I have a logged in session 49 | const optionsStorage = { 50 | getOptions: jest.fn().mockReturnValue({ 51 | providerUrl: 'https://pod.provider.example', 52 | }), 53 | } as unknown as OptionsStorage; 54 | const session = { 55 | info: { isLoggedIn: true }, 56 | logout: jest.fn(), 57 | } as unknown as Session; 58 | // when I log out 59 | const api = new AuthenticationApi(session, optionsStorage); 60 | await api.logout(); 61 | // then I am logged out from my session 62 | expect(session.logout).toHaveBeenCalled(); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /dev/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | getDefaultSession, 3 | handleIncomingRedirect, 4 | Session, 5 | } from '@inrupt/solid-client-authn-browser'; 6 | import React from 'react'; 7 | import ReactDOM from 'react-dom'; 8 | 9 | import contentCss from '../src/assets/content.css'; 10 | import { createMessageHandler } from '../src/background/createMessageHandler'; 11 | import { MessageHandler } from '../src/background/MessageHandler'; 12 | import { PageContent } from '../src/content/page-overlay/PageContent'; 13 | import { OptionsStorage } from '../src/options/OptionsStorage'; 14 | 15 | let handler: MessageHandler; 16 | 17 | chrome.runtime = { 18 | id: 'fake-extension-id', 19 | onMessage: () => { 20 | return ''; 21 | }, 22 | sendMessage: async (message: any, sendResponse: any) => { 23 | const result = await handler.handleMessage(message, null); 24 | sendResponse(result); 25 | }, 26 | openOptionsPage: () => (window.location.href = '/options.html'), 27 | } as unknown as typeof chrome.runtime; 28 | 29 | chrome.identity = { 30 | getRedirectURL: () => window.location.href, 31 | } as unknown as typeof chrome.identity; 32 | 33 | chrome.extension = { 34 | getURL: (path: string) => `chrome-extension://${chrome.runtime.id}${path}`, 35 | } as unknown as typeof chrome.extension; 36 | 37 | async function handleRedirectAfterLogin() { 38 | await handleIncomingRedirect(); 39 | return getDefaultSession(); 40 | } 41 | 42 | const providerUrl = 'http://localhost:3000'; 43 | 44 | const optionsStorage = { 45 | getOptions: () => ({ 46 | providerUrl, 47 | containerUrl: 'http://localhost:3000/webclip/', 48 | }), 49 | } as unknown as OptionsStorage; 50 | 51 | handleRedirectAfterLogin().then((session: Session) => { 52 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 53 | // @ts-ignore: this dev environment is no chrome extension, so we use regular browser session here 54 | handler = createMessageHandler(session, optionsStorage); 55 | renderApp(session); 56 | }); 57 | 58 | const root = document.getElementById('webclip'); 59 | const shadowRoot = root.attachShadow({ mode: 'open' }); 60 | const container = document.createElement('div'); 61 | const styleTag = document.createElement('style'); 62 | styleTag.innerHTML = contentCss; 63 | shadowRoot.appendChild(styleTag); 64 | shadowRoot.appendChild(container); 65 | 66 | function renderApp(session: Session) { 67 | ReactDOM.render( 68 | { 70 | ReactDOM.unmountComponentAtNode(container); 71 | console.log('reload the page to re-open WebClip in dev mode'); 72 | }} 73 | sessionInfo={session.info} 74 | providerUrl={providerUrl} 75 | />, 76 | container 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/api/StorageApi.ts: -------------------------------------------------------------------------------- 1 | import { Fetcher, LiveStore, UpdateManager } from 'rdflib'; 2 | import { Storage } from '../domain/Storage'; 3 | import { getContainerUrl } from '../store/getContainerUrl'; 4 | import { StorageStore } from '../store/StorageStore'; 5 | 6 | export class StorageApi { 7 | private store: StorageStore; 8 | private fetcher: Fetcher; 9 | private updater: UpdateManager; 10 | 11 | constructor(private webId: string, liveStore: LiveStore) { 12 | this.store = new StorageStore(liveStore); 13 | this.fetcher = liveStore.fetcher; 14 | this.updater = liveStore.updater; 15 | } 16 | 17 | async findStorage(): Promise { 18 | try { 19 | await this.fetcher.load(this.webId); 20 | const storageFromProfile = this.store.getStorageForWebId(this.webId); 21 | if (!storageFromProfile) { 22 | return await this.findStorageInHierarchy(this.webId); 23 | } 24 | return storageFromProfile; 25 | } catch (err) { 26 | console.log(err); 27 | return null; 28 | } 29 | } 30 | 31 | private async findStorageInHierarchy(url: string): Promise { 32 | const containerUrl = getContainerUrl(url); 33 | if (!containerUrl) { 34 | return null; 35 | } 36 | await this.fetcher.load(containerUrl); 37 | if (this.store.isStorage(containerUrl)) { 38 | return new Storage(containerUrl); 39 | } else { 40 | return this.findStorageInHierarchy(containerUrl); 41 | } 42 | } 43 | 44 | /** 45 | * tries to ensure, that the given container URL points to a valid container. 46 | * Therefore, the container must exist and be editable by the current user. 47 | * If the container does not exist, it tries to create a container at that location. 48 | * Returns true if finally a valid container exists at the URL, or false otherwise. 49 | * @param containerUrl 50 | */ 51 | async ensureValidContainer(containerUrl: string): Promise { 52 | try { 53 | await this.fetcher.load(containerUrl, { 54 | force: true, // the load is forced, so that the user can retry, after creating a container manually 55 | }); 56 | } catch (err) { 57 | if (err.status === 404) { 58 | return await this.tryToCreateIndexAtContainer(containerUrl); 59 | } 60 | console.log(err); 61 | return false; 62 | } 63 | return ( 64 | this.store.isContainer(containerUrl) && 65 | !!this.updater.editable(containerUrl) 66 | ); 67 | } 68 | 69 | private async tryToCreateIndexAtContainer(containerUrl: string) { 70 | try { 71 | await this.updater.update( 72 | [], 73 | this.store.createIndexDocumentStatement(containerUrl) 74 | ); 75 | return true; 76 | } catch (err) { 77 | console.log(err); 78 | return false; 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/options/connection-established/useConnectionEstablished.spec.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { when } from 'jest-when'; 3 | import { SolidSession } from '../../api/SolidSession'; 4 | import { sendMessage } from '../../chrome/sendMessage'; 5 | import { useAuthentication } from '../auth/AuthenticationContext'; 6 | import { useOptions } from '../OptionsContext'; 7 | import { ActionType, Dispatch } from '../reducer'; 8 | import { initialState } from '../useOptionsPage'; 9 | import { useConnectionEstablished } from './useConnectionEstablished'; 10 | 11 | jest.mock('../OptionsContext'); 12 | jest.mock('../auth/AuthenticationContext'); 13 | jest.mock('../../chrome/sendMessage'); 14 | 15 | describe('useConnectionEstablished', () => { 16 | let session: SolidSession; 17 | let dispatch: Dispatch; 18 | 19 | beforeEach(() => { 20 | when(sendMessage).mockResolvedValue(null); 21 | dispatch = jest.fn(); 22 | session = { 23 | logout: jest.fn().mockResolvedValue(null), 24 | } as unknown as SolidSession; 25 | when(useAuthentication).mockReturnValue({ 26 | session, 27 | redirectUrl: '', 28 | }); 29 | }); 30 | 31 | it('returns provider url', () => { 32 | when(useOptions).mockReturnValue({ 33 | state: { 34 | ...initialState, 35 | value: { 36 | ...initialState.value, 37 | providerUrl: 'https://provider.test', 38 | }, 39 | }, 40 | dispatch, 41 | }); 42 | const render = renderHook(() => useConnectionEstablished()); 43 | expect(render.result.current).toMatchObject({ 44 | providerUrl: 'https://provider.test', 45 | }); 46 | }); 47 | 48 | it('returns container url', () => { 49 | when(useOptions).mockReturnValue({ 50 | state: { 51 | ...initialState, 52 | value: { 53 | ...initialState.value, 54 | containerUrl: 'https://provider.test/alice/webclip', 55 | }, 56 | }, 57 | dispatch, 58 | }); 59 | const render = renderHook(() => useConnectionEstablished()); 60 | expect(render.result.current).toMatchObject({ 61 | containerUrl: 'https://provider.test/alice/webclip', 62 | }); 63 | }); 64 | 65 | describe('on disconnect', () => { 66 | beforeEach(() => { 67 | when(useOptions).mockReturnValue({ 68 | state: { 69 | ...initialState, 70 | }, 71 | dispatch, 72 | }); 73 | const render = renderHook(() => useConnectionEstablished()); 74 | render.result.current.disconnect(); 75 | }); 76 | it('dispatches disconnected pod event', () => { 77 | expect(dispatch).toHaveBeenCalledWith({ 78 | type: ActionType.DISCONNECTED_POD, 79 | }); 80 | }); 81 | 82 | it('logs out from current session', () => { 83 | expect(session.logout).toHaveBeenCalled(); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/content/authorization-page/AuthorizationPage.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Session } from '@inrupt/solid-client-authn-browser'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { when } from 'jest-when'; 4 | import React from 'react'; 5 | import { ExtensionUrl } from '../../chrome/urls'; 6 | import { AuthorizationPage } from './AuthorizationPage'; 7 | import { useAuthorization } from './useAuthorization'; 8 | 9 | jest.mock('./useAuthorization'); 10 | 11 | describe('AuthorizationPage', () => { 12 | it('describes what is happening while loading', () => { 13 | const session = {} as Session; 14 | const providerUrl = 'https://pod.test'; 15 | const extensionUrl = new ExtensionUrl('chrome-extension://extension-id/'); 16 | when(useAuthorization) 17 | .calledWith(session, providerUrl, extensionUrl) 18 | .mockReturnValue({ 19 | loading: true, 20 | success: false, 21 | error: null, 22 | }); 23 | render( 24 | 29 | ); 30 | expect( 31 | screen.getByText('Please wait, while WebClip is being authorized.') 32 | ).toBeInTheDocument(); 33 | }); 34 | 35 | it('shows success message after everything is finished', () => { 36 | const session = {} as Session; 37 | const providerUrl = 'https://pod.test'; 38 | const extensionUrl = new ExtensionUrl('chrome-extension://extension-id/'); 39 | when(useAuthorization) 40 | .calledWith(session, providerUrl, extensionUrl) 41 | .mockReturnValue({ 42 | loading: false, 43 | success: true, 44 | error: null, 45 | }); 46 | render( 47 | 52 | ); 53 | expect( 54 | screen.getByText( 55 | 'All done! You can now close this window and start using WebClip.' 56 | ) 57 | ).toBeInTheDocument(); 58 | }); 59 | 60 | it('shows error message when anything failed', () => { 61 | const session = {} as Session; 62 | const providerUrl = 'https://pod.test'; 63 | const extensionUrl = new ExtensionUrl('chrome-extension://extension-id/'); 64 | when(useAuthorization) 65 | .calledWith(session, providerUrl, extensionUrl) 66 | .mockReturnValue({ 67 | loading: false, 68 | success: false, 69 | error: new Error('test error'), 70 | }); 71 | render( 72 | 77 | ); 78 | expect( 79 | screen.getByText('Unfortunately something went wrong:') 80 | ).toBeInTheDocument(); 81 | expect(screen.getByText('test error')).toBeInTheDocument(); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/options/connect-pod/useLogin.spec.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { when } from 'jest-when'; 3 | import { Session } from '../../solid-client-authn-chrome-ext/Session'; 4 | import { useAuthentication } from '../auth/AuthenticationContext'; 5 | import { useLogin } from './useLogin'; 6 | 7 | jest.mock('../auth/AuthenticationContext'); 8 | 9 | describe('useLogin', () => { 10 | let session: Session; 11 | beforeEach(() => { 12 | session = { 13 | info: { 14 | isLoggedIn: true, 15 | webId: 'http://pod.test/alice#me', 16 | }, 17 | login: jest.fn().mockResolvedValue(undefined), 18 | } as unknown as Session; 19 | when(useAuthentication).mockReturnValue({ 20 | session, 21 | redirectUrl: 'https://redirect.test', 22 | }); 23 | }); 24 | 25 | it('returns loading false', () => { 26 | const { result } = renderHook(() => 27 | useLogin('https://pod.test', () => null) 28 | ); 29 | expect(result.current).toMatchObject({ 30 | loading: false, 31 | }); 32 | }); 33 | 34 | it('calls session login on login', async () => { 35 | const { result } = renderHook(() => 36 | useLogin('https://pod.test', () => null) 37 | ); 38 | await act(async () => { 39 | await result.current.login(); 40 | }); 41 | expect(session.login).toHaveBeenCalledWith({ 42 | oidcIssuer: 'https://pod.test', 43 | redirectUrl: 'https://redirect.test', 44 | }); 45 | }); 46 | 47 | it('calls on login after login', async () => { 48 | const onLogin = jest.fn(); 49 | const { result } = renderHook(() => useLogin('https://pod.test', onLogin)); 50 | await act(async () => { 51 | await result.current.login(); 52 | }); 53 | expect(onLogin).toHaveBeenCalledWith({ 54 | isLoggedIn: true, 55 | webId: 'http://pod.test/alice#me', 56 | }); 57 | }); 58 | 59 | it('indicates loading while logging in', async () => { 60 | const { result } = renderHook(() => 61 | useLogin('https://pod.test', () => null) 62 | ); 63 | await act(async () => { 64 | await result.current.login(); 65 | }); 66 | expect(result.all).toHaveLength(3); 67 | expect(result.all[0]).toMatchObject({ 68 | loading: false, 69 | }); 70 | expect(result.all[1]).toMatchObject({ 71 | loading: true, 72 | }); 73 | expect(result.all[2]).toMatchObject({ 74 | loading: false, 75 | }); 76 | }); 77 | 78 | it('indicates error when login fails', async () => { 79 | when(session.login).mockRejectedValue(new Error('login failed')); 80 | const { result } = renderHook(() => 81 | useLogin('https://pod.test', () => null) 82 | ); 83 | await act(async () => { 84 | await result.current.login(); 85 | }); 86 | expect(result.current).toMatchObject({ 87 | loading: false, 88 | error: new Error('login failed'), 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/options/authorization-section/GrantAccess.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ExtensionUrl } from '../../chrome/urls'; 3 | 4 | interface Props { 5 | authorizationPageBaseUrl: string; 6 | extensionUrl: ExtensionUrl; 7 | } 8 | 9 | export const GrantAccess = ({ 10 | authorizationPageBaseUrl, 11 | extensionUrl, 12 | }: Props) => ( 13 |
14 |
15 | 23 | 28 | 29 | You need to grant access. 30 |
31 |
32 |
33 | Extension Origin 34 |
35 | {extensionUrl.origin} 36 | 42 | 50 | 55 | 56 | Grant access 57 | 58 |
59 |
60 |

61 | Instead of using the link above you may add the extension origin as a 62 | trusted app manually, or use the SolidOS data browser,{' '} 63 | 67 | as described in the user guide 68 | 69 | . You need to grant at least Read, Write and{' '} 70 | Append access. 71 |

72 |
73 |
74 | ); 75 | -------------------------------------------------------------------------------- /src/store/createAboutStatements.spec.ts: -------------------------------------------------------------------------------- 1 | import { lit, st, sym } from 'rdflib'; 2 | import { createAboutStatements } from './createAboutStatements'; 3 | 4 | describe('create about statements', () => { 5 | it('no about statements are created if there are no statements at all', () => { 6 | const abouts = createAboutStatements( 7 | sym('https://page.example/'), 8 | [], 9 | sym('https://pod.example/webclip/1') 10 | ); 11 | expect(abouts).toEqual([]); 12 | }); 13 | 14 | it('an about statement is created if there is any statement', () => { 15 | const abouts = createAboutStatements( 16 | sym('https://page.example/'), 17 | [ 18 | st( 19 | sym('https://page.example/#thing'), 20 | sym('http://any.example'), 21 | lit('anything') 22 | ), 23 | ], 24 | sym('https://pod.example/webclip/1') 25 | ); 26 | expect(abouts).toEqual([ 27 | st( 28 | sym('https://page.example/'), 29 | sym('http://schema.org/about'), 30 | sym('https://page.example/#thing'), 31 | sym('https://pod.example/webclip/1') 32 | ), 33 | ]); 34 | }); 35 | 36 | it('no about statement is created if the thing is object of another statement', () => { 37 | const thing = sym('https://page.example/#secondlevel'); 38 | const abouts = createAboutStatements( 39 | sym('https://page.example/'), 40 | [ 41 | st(thing, sym('http://any.example'), lit('anything')), 42 | st( 43 | sym('https://page.example/#toplevel'), 44 | sym('http://other.example'), 45 | thing 46 | ), 47 | ], 48 | sym('https://pod.example/webclip/1') 49 | ); 50 | expect(abouts).toEqual([ 51 | st( 52 | sym('https://page.example/'), 53 | sym('http://schema.org/about'), 54 | sym('https://page.example/#toplevel'), 55 | sym('https://pod.example/webclip/1') 56 | ), 57 | ]); 58 | }); 59 | 60 | it('only one about statement is created if there are several statements about a thing', () => { 61 | const thing = sym('https://page.example/#thing'); 62 | const abouts = createAboutStatements( 63 | sym('https://page.example/'), 64 | [ 65 | st(thing, sym('http://any.example'), lit('anything')), 66 | st(thing, sym('http://other.example'), lit('anything')), 67 | ], 68 | sym('https://pod.example/webclip/1') 69 | ); 70 | expect(abouts).toEqual([ 71 | st( 72 | sym('https://page.example/'), 73 | sym('http://schema.org/about'), 74 | thing, 75 | sym('https://pod.example/webclip/1') 76 | ), 77 | ]); 78 | }); 79 | 80 | it('the page is not about itself', () => { 81 | const page = sym('https://page.example/'); 82 | const abouts = createAboutStatements( 83 | page, 84 | [ 85 | st(page, sym('http://any.example'), lit('anything')), 86 | st(page, sym('http://other.example'), lit('anything')), 87 | ], 88 | sym('https://pod.example/webclip/1') 89 | ); 90 | expect(abouts).toEqual([]); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/solid-client-authn-chrome-ext/getClientAuthentication.spec.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import { getClientAuthentication } from './getClientAuthentication'; 3 | import { launchWebAuthFlow } from './launchWebAuthFlow'; 4 | import { 5 | jwksResponse, 6 | openIdConfiguration, 7 | registerResponse, 8 | tokenResponse, 9 | } from './mock-idp-responses'; 10 | 11 | jest.mock('./launchWebAuthFlow'); 12 | 13 | jest.mock('jose', () => ({ 14 | generateKeyPair: () => ({ 15 | privateKey: { 16 | alg: 'RSA', 17 | }, 18 | publicKey: {}, 19 | }), 20 | exportJWK: (publicKey: string) => Promise.resolve(publicKey), 21 | importJWK: () => Promise.resolve(), 22 | SignJWT: () => ({ 23 | setProtectedHeader: () => ({ 24 | setIssuedAt: () => ({ 25 | sign: () => ({}), 26 | }), 27 | }), 28 | }), 29 | jwtVerify: () => 30 | Promise.resolve({ 31 | payload: { 32 | webid: 'https://pod.test/alice', 33 | sub: 'sub', 34 | }, 35 | }), 36 | })); 37 | 38 | describe('getClientAuthentication', () => { 39 | it('can login', async () => { 40 | nock('https://pod.test') 41 | .persist() 42 | .get('/.well-known/openid-configuration') 43 | .reply(200, openIdConfiguration, { 'Access-Control-Allow-Origin': '*' }); 44 | 45 | nock('https://pod.test') 46 | .post('/idp/reg') 47 | .reply(200, registerResponse, { 'Access-Control-Allow-Origin': '*' }); 48 | 49 | nock('https://pod.test') 50 | .post('/idp/token') 51 | .reply(200, tokenResponse, { 'Access-Control-Allow-Origin': '*' }); 52 | 53 | nock('https://pod.test') 54 | .get('/idp/jwks') 55 | .reply(200, jwksResponse, { 'Access-Control-Allow-Origin': '*' }); 56 | 57 | nock('https://pod.test') 58 | .get('/alice') 59 | .reply(200, {}, { 'Access-Control-Allow-Origin': '*' }); 60 | 61 | const redirectCallback = jest.fn(); 62 | const clientAuthentication = getClientAuthentication(redirectCallback); 63 | 64 | await clientAuthentication.login( 65 | { 66 | sessionId: 'test-session', 67 | oidcIssuer: 'https://pod.test', 68 | redirectUrl: 'https://example.test/redirect-url', 69 | tokenType: 'DPoP', 70 | }, 71 | null 72 | ); 73 | 74 | expect(launchWebAuthFlow).toHaveBeenCalled(); 75 | 76 | const returnToExtension = (launchWebAuthFlow as jest.Mock).mock.calls[0][1]; 77 | await returnToExtension( 78 | 'https://example.test/redirect-url?code=123&state=01111111011141119111011111111111' 79 | ); 80 | 81 | expect(redirectCallback).toHaveBeenCalledWith( 82 | expect.objectContaining({ 83 | clientAppId: 'mock-client-id', 84 | clientAppSecret: 'mock-client-secret', 85 | expirationDate: null, 86 | isLoggedIn: true, 87 | issuer: 'https://pod.test/', 88 | redirectUrl: 89 | 'https://example.test/redirect-url?state=01111111011141119111011111111111', 90 | refreshToken: undefined, 91 | sessionId: 'test-session', 92 | tokenType: 'DPoP', 93 | webId: 'https://pod.test/alice', 94 | }) 95 | ); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/options/OptionsPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSolidApis } from '../api/useSolidApis'; 3 | import { Session } from '../solid-client-authn-chrome-ext/Session'; 4 | import { AuthenticationContext } from './auth/AuthenticationContext'; 5 | import { ApiContext } from '../api/ApiContext'; 6 | import { AuthorizationSection } from './authorization-section/AuthorizationSection'; 7 | import { ChooseStorage } from './choose-storage/ChooseStorage'; 8 | import { ConnectPodSection } from './connect-pod/ConnectPodSection'; 9 | import { ConnectionEstablished } from './connection-established/ConnectionEstablished'; 10 | import { HelpSection } from './HelpSection'; 11 | import { OptionsContext } from './OptionsContext'; 12 | import { useOptionsPage } from './useOptionsPage'; 13 | 14 | interface Props { 15 | session: Session; 16 | } 17 | 18 | const AllSaved = () => { 19 | return ( 20 | 21 | 27 | 32 | 33 | All settings saved 34 | 35 | ); 36 | }; 37 | 38 | export const OptionsPage = ({ session }: Props) => { 39 | const { state, dispatch, redirectUrl, extensionUrl } = 40 | useOptionsPage(session); 41 | 42 | const apis = useSolidApis(session); 43 | 44 | if (state.loading) { 45 | return

Loading...

; 46 | } 47 | 48 | const trustedApp = state.value.trustedApp; 49 | const isLoggedIn = state.sessionInfo.isLoggedIn; 50 | const containerUrl = state.value.containerUrl; 51 | 52 | return ( 53 | 59 | 60 | 61 |
62 |

63 | Setup WebClip {state.saved && } 64 |

65 | {!isLoggedIn && (!trustedApp || !containerUrl) && ( 66 | 67 | )} 68 | {isLoggedIn && !trustedApp && ( 69 | 73 | )} 74 | 75 | {isLoggedIn && trustedApp && !containerUrl && } 76 | 77 | {trustedApp && containerUrl && } 78 | 79 |
80 |
81 |
82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/options/authorization-section/AuthorizationSection.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { when } from 'jest-when'; 4 | import { ExtensionUrl } from '../../chrome/urls'; 5 | import { ProfileApi } from '../../api/ProfileApi'; 6 | import { AuthorizationSection } from './AuthorizationSection'; 7 | import { useCheckAccessPermissions } from './useCheckAccessPermissions'; 8 | 9 | jest.mock('./useCheckAccessPermissions'); 10 | 11 | describe('AuthorizationSection', () => { 12 | describe('while checking access permissions', () => { 13 | beforeEach(() => { 14 | const profileApi = { 15 | getProfileDocUrl: () => 'https://pod.example/alice', 16 | } as unknown as ProfileApi; 17 | when(useCheckAccessPermissions) 18 | .calledWith( 19 | new ExtensionUrl('chrome-extension://extension-id/'), 20 | new URL('https://extension-id.chromiumapp.org') 21 | ) 22 | .mockReturnValue({ 23 | checking: true, 24 | profileApi, 25 | }); 26 | 27 | render( 28 | 32 | ); 33 | }); 34 | 35 | it('indicates that check is in progress', () => { 36 | expect( 37 | screen.queryByText('Checking access permissions') 38 | ).toBeInTheDocument(); 39 | }); 40 | 41 | it('displays the extension url', () => { 42 | expect( 43 | screen.queryByText('chrome-extension://extension-id') 44 | ).toBeInTheDocument(); 45 | }); 46 | }); 47 | 48 | describe('after checking access permission', () => { 49 | beforeEach(() => { 50 | const profileApi = { 51 | getProfileDocUrl: () => 'https://pod.example/alice', 52 | } as unknown as ProfileApi; 53 | when(useCheckAccessPermissions) 54 | .calledWith( 55 | new ExtensionUrl('chrome-extension://extension-id/'), 56 | new URL('https://extension-id.chromiumapp.org') 57 | ) 58 | .mockReturnValue({ 59 | checking: false, 60 | profileApi, 61 | }); 62 | 63 | render( 64 | 68 | ); 69 | }); 70 | 71 | it('indicates that access needs to be granted', () => { 72 | expect( 73 | screen.queryByText('You need to grant access.') 74 | ).toBeInTheDocument(); 75 | }); 76 | 77 | it('has a link to grant access', () => { 78 | const grantAccessLink = screen 79 | .queryAllByRole('link') 80 | .find((it) => it.textContent === 'Grant access'); 81 | expect(grantAccessLink).toBeInTheDocument(); 82 | expect(grantAccessLink.getAttribute('href')).toBe( 83 | `https://pod.example/alice/.web-clip/${chrome.runtime.id}` 84 | ); 85 | }); 86 | 87 | it('displays the extension url', () => { 88 | expect( 89 | screen.queryByText('chrome-extension://extension-id') 90 | ).toBeInTheDocument(); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # WebClip 2 | 3 | A Chrome extension to extract structured data from any web page and store it to 4 | a Solid Pod. 5 | 6 | ## Install 7 | 8 | You can install the extension easily 9 | [via the Chrome Web Store](https://chrome.google.com/webstore/detail/webclip-clip-all-the-thin/mfgjcggbpdkbnnpgllaicoeplfgkfnkj). 10 | 11 | ## Start locally 12 | 13 | First install all dependencies by running 14 | 15 | ```shell 16 | npm install 17 | ``` 18 | 19 | ### Limited development mode via Webpack dev server 20 | 21 | To run a pure web application version of the extension execute 22 | 23 | ```shell 24 | npm run dev 25 | ``` 26 | 27 | Be aware that this mode mocks the chrome API and not everything will work the 28 | same way as the real extension would. So make sure to always 29 | [test the full extension via chrome](#full-extension-via-chrome). 30 | 31 | You can use the WebClip popup [on the example page](http://localhost:8080) with 32 | a login to [the local development pod](#local-pod-for-testing). 33 | 34 | The option page can also be opened via the icon in the UI, or directly visited 35 | at http://localhost:8080/options.html. 36 | 37 | Some features, like granting access to a remote pod, can not be tested that way, 38 | but the limited dev mode is a good way to test simple UI-focused changes, 39 | without having to rebuild and reload the whole plugin. 40 | 41 | To test the real extension, integrated with the browser API, see section 42 | ["Full extension via chrome"](#full-extension-via-chrome). 43 | 44 | ### Local pod for testing 45 | 46 | You need [Docker](https://www.docker.com) to do this. 47 | 48 | To start a Community Solid Server instance locally for testing run: 49 | 50 | ```shell 51 | npm run pod:up 52 | ``` 53 | 54 | The server will be running as a docker container in the background and can be 55 | accessed on http://localhost:3000. 56 | 57 | The data of it is stored at `./dev/pod`. 58 | 59 | | Log in | | 60 | | -------- | ----------------- | 61 | | email | webclip@mail.test | 62 | | password | webclip-dev-pod | 63 | 64 | To view the server logs run: 65 | 66 | ```shell 67 | npm run pod:logs 68 | ``` 69 | 70 | To stop the server execute: 71 | 72 | ```shell 73 | npm run pod:down 74 | ``` 75 | 76 | ### Full extension via chrome 77 | 78 | To start webpack in watch mode run 79 | 80 | ```shell 81 | npm start 82 | ``` 83 | 84 | In Chrome: 85 | 86 | 1. visit chrome://extensions/ 87 | 2. enable the developer mode 88 | 3. Load unpacked extension (choose the project's build folder) 89 | 90 | ## Release on Chrome Web Store 91 | 92 | All commits to the main branch trigger a Github Actions CI/CD build, that 93 | creates or 94 | [updates a draft release](https://github.com/codecentric/web-clip/releases) with 95 | the version from package.json. The ZIP file can then be downloaded locally and 96 | uploaded manually via the 97 | [Chrome Developer Dashboard](https://chrome.google.com/webstore/devconsole/ee35c951-053f-4723-80b8-e4420a571f64/mfgjcggbpdkbnnpgllaicoeplfgkfnkj/edit/package?hl=de). 98 | To create an official release on GitHub, the `release` step in the 99 | [CI/CD Github Action](https://github.com/codecentric/web-clip/actions/workflows/ci-cd.yml) 100 | needs to be triggered manually. 101 | -------------------------------------------------------------------------------- /src/content/page-overlay/PageContent.tsx: -------------------------------------------------------------------------------- 1 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 2 | import React from 'react'; 3 | import { LoginButton } from './LoginButton'; 4 | import { openOptions } from './openOptions'; 5 | import { SetupButton } from './SetupButton'; 6 | import { ToolbarContainer } from './ToolbarContainer'; 7 | import { useSessionInfo } from './useSessionInfo'; 8 | 9 | interface PageContentProps { 10 | sessionInfo: ISessionInfo; 11 | providerUrl: string; 12 | close: () => void; 13 | } 14 | 15 | export const PageContent = ({ 16 | sessionInfo, 17 | providerUrl, 18 | close, 19 | }: PageContentProps) => { 20 | const currentSessionInfo = useSessionInfo(sessionInfo); 21 | return ( 22 |
23 |
24 | { 26 | close(); 27 | await openOptions(); 28 | }} 29 | /> 30 | 31 |
32 |
33 |

WebClip

34 | {currentSessionInfo.isLoggedIn ? ( 35 | 36 | ) : providerUrl ? ( 37 | 38 | ) : ( 39 | 40 | )} 41 |
42 | ); 43 | }; 44 | 45 | interface ButtonProps { 46 | onClick: () => void; 47 | } 48 | 49 | const OptionsButton = ({ onClick }: ButtonProps) => ( 50 | 75 | ); 76 | 77 | const CloseButton = ({ onClick }: ButtonProps) => ( 78 | 98 | ); 99 | -------------------------------------------------------------------------------- /src/options/reducer.ts: -------------------------------------------------------------------------------- 1 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 2 | import { Options } from './optionsStorageApi'; 3 | 4 | interface PageState { 5 | loading: boolean; 6 | saved: boolean; 7 | unsavedChanges: boolean; 8 | sessionInfo: ISessionInfo; 9 | value?: T; 10 | } 11 | 12 | export enum ActionType { 13 | OPTIONS_LOADED = 'OPTIONS_LOADED', 14 | SET_PROVIDER_URL = 'SET_PROVIDER_URL', 15 | OPTIONS_SAVED = 'OPTIONS_SAVED', 16 | LOGGED_IN = 'LOGGED_IN', 17 | TRUSTED_APP = 'TRUSTED_APP', 18 | DISCONNECTED_POD = 'DISCONNECTED_POD', 19 | SELECTED_STORAGE_CONTAINER = 'SELECTED_STORAGE_CONTAINER', 20 | } 21 | 22 | export type State = PageState; 23 | export type Action = 24 | | OptionsLoaded 25 | | SetProviderUrl 26 | | OptionsSaved 27 | | LoggedIn 28 | | TrustedApp 29 | | SelectedStorage 30 | | DisconnectedPod; 31 | export type Dispatch = (action: Action) => void; 32 | 33 | interface OptionsLoaded { 34 | type: ActionType.OPTIONS_LOADED; 35 | payload: Options; 36 | } 37 | 38 | interface SetProviderUrl { 39 | type: ActionType.SET_PROVIDER_URL; 40 | payload: string; 41 | } 42 | 43 | interface LoggedIn { 44 | type: ActionType.LOGGED_IN; 45 | payload: ISessionInfo; 46 | } 47 | 48 | interface OptionsSaved { 49 | type: ActionType.OPTIONS_SAVED; 50 | } 51 | 52 | interface TrustedApp { 53 | type: ActionType.TRUSTED_APP; 54 | } 55 | 56 | interface SelectedStorage { 57 | type: ActionType.SELECTED_STORAGE_CONTAINER; 58 | payload: string; 59 | } 60 | 61 | interface DisconnectedPod { 62 | type: ActionType.DISCONNECTED_POD; 63 | } 64 | 65 | export default ( 66 | state: PageState, 67 | action: Action 68 | ): PageState => { 69 | switch (action.type) { 70 | case ActionType.OPTIONS_LOADED: 71 | return { 72 | ...state, 73 | loading: false, 74 | value: action.payload, 75 | }; 76 | case ActionType.SET_PROVIDER_URL: 77 | return { 78 | ...state, 79 | value: { 80 | ...state.value, 81 | trustedApp: false, 82 | providerUrl: action.payload, 83 | }, 84 | }; 85 | case ActionType.OPTIONS_SAVED: 86 | return { 87 | ...state, 88 | unsavedChanges: false, 89 | saved: true, 90 | }; 91 | case ActionType.LOGGED_IN: 92 | return { 93 | ...state, 94 | unsavedChanges: true, // assume a new provider url needs to be saved 95 | sessionInfo: action.payload, 96 | }; 97 | case ActionType.TRUSTED_APP: 98 | return { 99 | ...state, 100 | unsavedChanges: true, 101 | value: { 102 | ...state.value, 103 | trustedApp: true, 104 | }, 105 | }; 106 | case ActionType.SELECTED_STORAGE_CONTAINER: 107 | return { 108 | ...state, 109 | value: { 110 | ...state.value, 111 | containerUrl: action.payload, 112 | }, 113 | unsavedChanges: true, 114 | }; 115 | case ActionType.DISCONNECTED_POD: 116 | return { 117 | ...state, 118 | unsavedChanges: true, 119 | value: { 120 | ...state.value, 121 | providerUrl: '', 122 | containerUrl: '', 123 | trustedApp: false, 124 | }, 125 | }; 126 | } 127 | }; 128 | -------------------------------------------------------------------------------- /src/content/page-overlay/PageContent.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ISessionInfo } from '@inrupt/solid-client-authn-browser'; 3 | import { render, screen } from '@testing-library/react'; 4 | import { PageContent } from './PageContent'; 5 | import { useSessionInfo } from './useSessionInfo'; 6 | import { openOptions } from './openOptions'; 7 | 8 | jest.mock('./useSessionInfo'); 9 | jest.mock('./openOptions'); 10 | 11 | describe('PageContent', () => { 12 | beforeEach(() => { 13 | (useSessionInfo as jest.Mock).mockReturnValue({ 14 | isLoggedIn: false, 15 | }); 16 | }); 17 | describe('without provider url', () => { 18 | it('shows setup button', () => { 19 | render( 20 | null} 24 | /> 25 | ); 26 | const setupButton = screen.queryByText('Get started'); 27 | expect(setupButton).toBeInTheDocument(); 28 | }); 29 | }); 30 | describe('when not logged in', () => { 31 | it('shows login button', () => { 32 | render( 33 | null} 37 | /> 38 | ); 39 | const loginButton = screen.queryByText('Login'); 40 | expect(loginButton).toBeInTheDocument(); 41 | }); 42 | }); 43 | describe('close icon', () => { 44 | it('is present', () => { 45 | render( 46 | null} 50 | /> 51 | ); 52 | const closeButton = screen.queryByLabelText('Close'); 53 | expect(closeButton).toBeInTheDocument(); 54 | }); 55 | 56 | it('calls the close callback when clicked', () => { 57 | const close = jest.fn(); 58 | render( 59 | 64 | ); 65 | const closeButton = screen.queryByLabelText('Close'); 66 | 67 | closeButton.click(); 68 | 69 | expect(close).toHaveBeenCalled(); 70 | }); 71 | }); 72 | 73 | describe('options icon', () => { 74 | it('is present', () => { 75 | render( 76 | null} 80 | /> 81 | ); 82 | const optionsButton = screen.queryByLabelText('Options'); 83 | expect(optionsButton).toBeInTheDocument(); 84 | }); 85 | 86 | it('opens options and closes when clicked', () => { 87 | const close = jest.fn(); 88 | render( 89 | 94 | ); 95 | const optionsButton = screen.queryByLabelText('Options'); 96 | 97 | optionsButton.click(); 98 | 99 | expect(openOptions).toHaveBeenCalled(); 100 | expect(close).toHaveBeenCalled(); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/store/StorageStore.spec.ts: -------------------------------------------------------------------------------- 1 | import { graph, parse, st, sym } from 'rdflib'; 2 | import { Storage } from '../domain/Storage'; 3 | import { StorageStore } from './StorageStore'; 4 | 5 | describe('StorageStore', () => { 6 | describe('getStorageForWebId', () => { 7 | it('does not return storage if store is empty', () => { 8 | const store = graph(); 9 | const storageStore = new StorageStore(store); 10 | const storage = storageStore.getStorageForWebId( 11 | 'https://alice.test/profile/card#me' 12 | ); 13 | expect(storage).toBe(null); 14 | }); 15 | 16 | it('returns storage linked from WebID', () => { 17 | const store = graph(); 18 | parse( 19 | ` 20 | @prefix space: . 21 | <#me> space:storage . 22 | `, 23 | store, 24 | 'https://alice.test/profile/card' 25 | ); 26 | const storageStore = new StorageStore(store); 27 | const storage = storageStore.getStorageForWebId( 28 | 'https://alice.test/profile/card#me' 29 | ); 30 | expect(storage).toEqual(new Storage('https://alice.test/')); 31 | }); 32 | }); 33 | describe('isContainer', () => { 34 | it('returns false if store is empty', () => { 35 | const store = graph(); 36 | const storageStore = new StorageStore(store); 37 | const result = storageStore.isContainer('https://alice.test/public/'); 38 | expect(result).toBe(false); 39 | }); 40 | it('returns true if url identifies a container', () => { 41 | const store = graph(); 42 | parse( 43 | ` 44 | @prefix ldp: . 45 | <> a ldp:Container . 46 | `, 47 | store, 48 | 'https://alice.test/public/' 49 | ); 50 | const storageStore = new StorageStore(store); 51 | const result = storageStore.isContainer('https://alice.test/public/'); 52 | expect(result).toBe(true); 53 | }); 54 | }); 55 | 56 | describe('isStorage', () => { 57 | it('returns false if store is empty', () => { 58 | const store = graph(); 59 | const storageStore = new StorageStore(store); 60 | const result = storageStore.isStorage('https://alice.test/public/'); 61 | expect(result).toBe(false); 62 | }); 63 | it('returns true if url identifies a storage', () => { 64 | const store = graph(); 65 | parse( 66 | ` 67 | @prefix space: . 68 | <> a space:Storage . 69 | `, 70 | store, 71 | 'https://alice.test/public/' 72 | ); 73 | const storageStore = new StorageStore(store); 74 | const result = storageStore.isStorage('https://alice.test/public/'); 75 | expect(result).toBe(true); 76 | }); 77 | }); 78 | describe('createIndexDocumentStatement', () => { 79 | it('creates a single statement to describe the index document', () => { 80 | const store = graph(); 81 | const storageStore = new StorageStore(store); 82 | const result = storageStore.createIndexDocumentStatement( 83 | 'https://alice.test/public/' 84 | ); 85 | expect(result).toEqual([ 86 | st( 87 | sym('https://alice.test/public/index.ttl'), 88 | sym('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), 89 | sym('http://www.w3.org/ns/ldp#Resource'), 90 | sym('https://alice.test/public/index.ttl') 91 | ), 92 | ]); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | WebClip Test Page 11 | 34 | 35 | 36 |
37 |

This is a test page for development

38 | 39 |
40 | iOS 41 | 42 | MONOPOLY 43 | by Electronic Arts 44 | 45 | **YOU VOTED & THE CAT’S OUT OF THE BAG** Thanks to the votes from YOU and thousands of loyal MONOPOLY Facebook fans from 185 different countries, the CAT mover is now available to play with in this latest update as well as in the classic board game version of MONOPOLY! 46 |
47 | 4 stars - 48 | 33 reviews 49 |
50 |
51 | Price: 1 52 | 53 | 54 | In Stock 55 |
56 | Version:1.2.50 57 | Updated: 58 | 59 | 09.11.2013 60 |
61 | Age4+ 62 |
63 |
64 | 65 |

66 | Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy 67 | nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut 68 | wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit 69 | lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum 70 | iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel 71 | illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto 72 | odio dignissim qui blandit praesent luptatum zzril delenit a 73 |

74 |

75 | Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy 76 | nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut 77 | wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit 78 | lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum 79 | iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel 80 | illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto 81 | odio dignissim qui blandit praesent luptatum zzril delenit a 82 |

83 | 84 | 85 | -------------------------------------------------------------------------------- /src/store/BookmarkStore.spec.ts: -------------------------------------------------------------------------------- 1 | import { graph, sym } from 'rdflib'; 2 | import { givenStoreContaining } from '../test/givenStoreContaining'; 3 | import { BookmarkStore } from './BookmarkStore'; 4 | 5 | describe('BookmarkStore', () => { 6 | describe('getIndexedBookmark', () => { 7 | it('does not find index bookmark in empty store', () => { 8 | const store = new BookmarkStore(graph()); 9 | const result = store.getIndexedBookmark( 10 | sym('https://page.test'), 11 | sym('https://pod.example/webclip/index.ttl') 12 | ); 13 | expect(result).toBeNull(); 14 | }); 15 | 16 | it('does not find bookmark, if it is not present in index', () => { 17 | const rdfGraph = givenStoreContaining( 18 | 'https://pod.example/webclip/index.ttl', 19 | ` 20 | @prefix schema: . 21 | 22 | a schema:BookmarkAction; schema:object . 23 | ` 24 | ); 25 | const store = new BookmarkStore(rdfGraph); 26 | const result = store.getIndexedBookmark( 27 | sym('https://page.test'), 28 | sym('https://pod.example/webclip/index.ttl') 29 | ); 30 | expect(result).toBeNull(); 31 | }); 32 | 33 | it('finds the bookmark, if it is present in index', () => { 34 | const rdfGraph = givenStoreContaining( 35 | 'https://pod.example/webclip/index.ttl', 36 | ` 37 | @prefix schema: . 38 | 39 | a schema:BookmarkAction; schema:object . 40 | ` 41 | ); 42 | const store = new BookmarkStore(rdfGraph); 43 | const result = store.getIndexedBookmark( 44 | sym('https://page.test'), 45 | sym('https://pod.example/webclip/index.ttl') 46 | ); 47 | expect(result).toEqual(sym('http://storage.example/webclip/relevant#it')); 48 | }); 49 | 50 | it('does not find bookmark, if the page is not linked by schema:object', () => { 51 | const rdfGraph = givenStoreContaining( 52 | 'https://pod.example/webclip/index.ttl', 53 | ` 54 | @prefix schema: . 55 | 56 | a schema:BookmarkAction; schema:irrelevant . 57 | ` 58 | ); 59 | const store = new BookmarkStore(rdfGraph); 60 | const result = store.getIndexedBookmark( 61 | sym('https://page.test'), 62 | sym('https://pod.example/webclip/index.ttl') 63 | ); 64 | expect(result).toBeNull(); 65 | }); 66 | 67 | it('only finds bookmarks within index document', () => { 68 | const rdfGraph = givenStoreContaining( 69 | 'https://pod.example/somewhere/else.ttl', 70 | ` 71 | @prefix schema: . 72 | 73 | a schema:BookmarkAction; schema:object . 74 | ` 75 | ); 76 | const store = new BookmarkStore(rdfGraph); 77 | const result = store.getIndexedBookmark( 78 | sym('https://page.test'), 79 | sym('https://pod.example/webclip/index.ttl') 80 | ); 81 | expect(result).toBeNull(); 82 | }); 83 | }); 84 | 85 | it('does not find bookmark, if it is not a schema:BookmarkAction', () => { 86 | const rdfGraph = givenStoreContaining( 87 | 'https://pod.example/webclip/index.ttl', 88 | ` 89 | @prefix schema: . 90 | 91 | a schema:WatchAction; schema:object . 92 | ` 93 | ); 94 | const store = new BookmarkStore(rdfGraph); 95 | const result = store.getIndexedBookmark( 96 | sym('https://page.test'), 97 | sym('https://pod.example/webclip/index.ttl') 98 | ); 99 | expect(result).toBeNull(); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/solid-client-authn-chrome-ext/mock-idp-responses.ts: -------------------------------------------------------------------------------- 1 | export const openIdConfiguration = { 2 | authorization_endpoint: 'https://pod.test/idp/auth', 3 | claims_parameter_supported: true, 4 | claims_supported: ['webid', 'client_id', 'sub', 'sid', 'auth_time', 'iss'], 5 | code_challenge_methods_supported: ['S256'], 6 | end_session_endpoint: 'https://pod.test/idp/session/end', 7 | grant_types_supported: ['implicit', 'authorization_code', 'refresh_token'], 8 | id_token_signing_alg_values_supported: ['HS256', 'RS256'], 9 | issuer: 'https://pod.test/', 10 | jwks_uri: 'https://pod.test/idp/jwks', 11 | registration_endpoint: 'https://pod.test/idp/reg', 12 | response_modes_supported: ['form_post', 'fragment', 'query'], 13 | response_types_supported: ['code id_token', 'code', 'id_token', 'none'], 14 | scopes_supported: ['openid', 'profile', 'offline_access'], 15 | subject_types_supported: ['public', 'pairwise'], 16 | token_endpoint_auth_methods_supported: [ 17 | 'none', 18 | 'client_secret_basic', 19 | 'client_secret_jwt', 20 | 'client_secret_post', 21 | 'private_key_jwt', 22 | ], 23 | token_endpoint_auth_signing_alg_values_supported: [ 24 | 'HS256', 25 | 'RS256', 26 | 'PS256', 27 | 'ES256', 28 | 'EdDSA', 29 | ], 30 | token_endpoint: 'https://pod.test/idp/token', 31 | request_object_signing_alg_values_supported: [ 32 | 'HS256', 33 | 'RS256', 34 | 'PS256', 35 | 'ES256', 36 | 'EdDSA', 37 | ], 38 | request_parameter_supported: false, 39 | request_uri_parameter_supported: true, 40 | require_request_uri_registration: true, 41 | userinfo_endpoint: 'https://pod.test/idp/me', 42 | userinfo_signing_alg_values_supported: ['HS256', 'RS256'], 43 | introspection_endpoint: 'https://pod.test/idp/token/introspection', 44 | introspection_endpoint_auth_methods_supported: [ 45 | 'none', 46 | 'client_secret_basic', 47 | 'client_secret_jwt', 48 | 'client_secret_post', 49 | 'private_key_jwt', 50 | ], 51 | introspection_endpoint_auth_signing_alg_values_supported: [ 52 | 'HS256', 53 | 'RS256', 54 | 'PS256', 55 | 'ES256', 56 | 'EdDSA', 57 | ], 58 | dpop_signing_alg_values_supported: ['RS256', 'PS256', 'ES256', 'EdDSA'], 59 | revocation_endpoint: 'https://pod.test/idp/token/revocation', 60 | revocation_endpoint_auth_methods_supported: [ 61 | 'none', 62 | 'client_secret_basic', 63 | 'client_secret_jwt', 64 | 'client_secret_post', 65 | 'private_key_jwt', 66 | ], 67 | revocation_endpoint_auth_signing_alg_values_supported: [ 68 | 'HS256', 69 | 'RS256', 70 | 'PS256', 71 | 'ES256', 72 | 'EdDSA', 73 | ], 74 | claim_types_supported: ['normal'], 75 | solid_oidc_supported: 'https://solidproject.org/TR/solid-oidc', 76 | }; 77 | 78 | export const registerResponse = { 79 | application_type: 'web', 80 | grant_types: ['authorization_code'], 81 | id_token_signed_response_alg: 'RS256', 82 | require_auth_time: false, 83 | response_types: ['code'], 84 | subject_type: 'pairwise', 85 | token_endpoint_auth_method: 'client_secret_basic', 86 | introspection_endpoint_auth_method: 'client_secret_basic', 87 | revocation_endpoint_auth_method: 'client_secret_basic', 88 | require_signed_request_object: false, 89 | client_id_issued_at: 1635173530, 90 | client_id: 'mock-client-id', 91 | client_name: 'My Example App', 92 | client_secret_expires_at: 0, 93 | client_secret: 'mock-client-secret', 94 | redirect_uris: ['https://example.test/redirect-url'], 95 | registration_client_uri: 'https://pod.test/idp/reg/mock-client-id', 96 | registration_access_token: 'mock-registration-access-token', 97 | }; 98 | 99 | export const tokenResponse = { 100 | access_token: 'mock-access-token', 101 | id_token: 'mock-id-token', 102 | token_type: 'DPoP', 103 | }; 104 | 105 | export const jwksResponse = { 106 | keys: [ 107 | { 108 | kid: 'mock-kid', 109 | kty: 'RSA', 110 | alg: 'RS256', 111 | key_ops: ['verify'], 112 | ext: true, 113 | n: 'n', 114 | e: 'AQAB', 115 | }, 116 | ], 117 | }; 118 | -------------------------------------------------------------------------------- /src/options/OptionsStorage.spec.ts: -------------------------------------------------------------------------------- 1 | import { when } from 'jest-when'; 2 | import { OptionsStorage } from './OptionsStorage'; 3 | import { load, onChanged } from './optionsStorageApi'; 4 | 5 | jest.mock('./optionsStorageApi'); 6 | 7 | describe('OptionsStorage', () => { 8 | beforeEach(() => { 9 | jest.resetAllMocks(); 10 | }); 11 | 12 | it('holds the loaded options initially', async () => { 13 | when(load).mockResolvedValue({ 14 | providerUrl: 'https://provider.test', 15 | containerUrl: 'https://container.test', 16 | trustedApp: true, 17 | }); 18 | const storage = new OptionsStorage(); 19 | await storage.init(); 20 | expect(storage.getOptions()).toEqual({ 21 | providerUrl: 'https://provider.test', 22 | containerUrl: 'https://container.test', 23 | trustedApp: true, 24 | }); 25 | }); 26 | 27 | it('holds changed and unchanged values after a change', async () => { 28 | when(load).mockResolvedValue({ 29 | providerUrl: 'https://provider.test', 30 | containerUrl: 'https://container.test', 31 | trustedApp: true, 32 | }); 33 | const storage = new OptionsStorage(); 34 | await storage.init(); 35 | expect(onChanged).toHaveBeenCalled(); 36 | const change = (onChanged as jest.Mock).mock.calls[0][0]; 37 | change( 38 | { 39 | providerUrl: { 40 | newValue: 'https://new.provider.test', 41 | oldValue: 'https://provider.test', 42 | }, 43 | containerUrl: { 44 | newValue: 'https://new.container.test', 45 | oldValue: 'https://container.test', 46 | }, 47 | }, 48 | 'sync' 49 | ); 50 | expect(storage.getOptions()).toEqual({ 51 | providerUrl: 'https://new.provider.test', 52 | containerUrl: 'https://new.container.test', 53 | trustedApp: true, 54 | }); 55 | }); 56 | 57 | it('ignore changes in other namspaces than sync', async () => { 58 | when(load).mockResolvedValue({ 59 | providerUrl: 'https://provider.test', 60 | containerUrl: 'https://container.test', 61 | trustedApp: true, 62 | }); 63 | const storage = new OptionsStorage(); 64 | await storage.init(); 65 | expect(onChanged).toHaveBeenCalled(); 66 | const change = (onChanged as jest.Mock).mock.calls[0][0]; 67 | change( 68 | { 69 | providerUrl: { 70 | newValue: 'https://new.provider.test', 71 | oldValue: 'https://provider.test', 72 | }, 73 | containerUrl: { 74 | newValue: 'https://new.container.test', 75 | oldValue: 'https://container.test', 76 | }, 77 | }, 78 | 'managed' 79 | ); 80 | expect(storage.getOptions()).toEqual({ 81 | providerUrl: 'https://provider.test', 82 | containerUrl: 'https://container.test', 83 | trustedApp: true, 84 | }); 85 | }); 86 | 87 | describe('events', () => { 88 | it('notifies about all changes', async () => { 89 | when(load).mockResolvedValue({ 90 | providerUrl: 'https://provider.test', 91 | containerUrl: 'https://container.test', 92 | trustedApp: true, 93 | }); 94 | const storage = new OptionsStorage(); 95 | await storage.init(); 96 | expect(onChanged).toHaveBeenCalled(); 97 | const providerUrlListener = jest.fn(); 98 | const containerUrlListener = jest.fn(); 99 | const trustedAppListener = jest.fn(); 100 | storage.on('providerUrl', providerUrlListener); 101 | storage.on('containerUrl', containerUrlListener); 102 | storage.on('trustedApp', trustedAppListener); 103 | const change = (onChanged as jest.Mock).mock.calls[0][0]; 104 | change( 105 | { 106 | providerUrl: { 107 | newValue: 'https://new.provider.test', 108 | oldValue: 'https://provider.test', 109 | }, 110 | containerUrl: { 111 | newValue: 'https://new.container.test', 112 | oldValue: 'https://container.test', 113 | }, 114 | }, 115 | 'sync' 116 | ); 117 | expect(providerUrlListener).toHaveBeenCalledWith({ 118 | newValue: 'https://new.provider.test', 119 | oldValue: 'https://provider.test', 120 | }); 121 | expect(containerUrlListener).toHaveBeenCalledWith({ 122 | newValue: 'https://new.container.test', 123 | oldValue: 'https://container.test', 124 | }); 125 | expect(trustedAppListener).not.toHaveBeenCalled(); 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /src/solid-client-authn-chrome-ext/ChromeExtensionRedirector.spec.ts: -------------------------------------------------------------------------------- 1 | import { IIncomingRedirectHandler } from '@inrupt/solid-client-authn-core'; 2 | import { ChromeExtensionRedirector } from './ChromeExtensionRedirector'; 3 | import { launchWebAuthFlow } from './launchWebAuthFlow'; 4 | 5 | jest.mock('./launchWebAuthFlow'); 6 | 7 | describe('ChromeExtensionRedirector', () => { 8 | beforeEach(() => { 9 | jest.resetAllMocks(); 10 | }); 11 | 12 | function mockRedirectHandler(): IIncomingRedirectHandler { 13 | return { 14 | handle: jest.fn(), 15 | canHandle: jest.fn(), 16 | }; 17 | } 18 | 19 | describe('redirect', () => { 20 | it('launches a web auth flow', () => { 21 | const redirectHandler = mockRedirectHandler(); 22 | const redirector = new ChromeExtensionRedirector( 23 | redirectHandler, 24 | () => null 25 | ); 26 | redirector.redirect('/redirect-url'); 27 | 28 | expect(launchWebAuthFlow).toHaveBeenCalledWith( 29 | { 30 | url: '/redirect-url', 31 | interactive: true, 32 | }, 33 | expect.anything() 34 | ); 35 | }); 36 | 37 | it('calls the redirect handler when web auth flow returns to extension', () => { 38 | const redirectHandler = mockRedirectHandler(); 39 | 40 | const redirector = new ChromeExtensionRedirector( 41 | redirectHandler, 42 | () => null 43 | ); 44 | redirector.redirect('/redirect-url'); 45 | 46 | const returnToExtension = (launchWebAuthFlow as jest.Mock).mock 47 | .calls[0][1]; 48 | 49 | returnToExtension('/redirect-url?code=123'); 50 | expect(redirectHandler.handle).toHaveBeenCalledWith( 51 | '/redirect-url?code=123', 52 | undefined 53 | ); 54 | }); 55 | 56 | it('calls the after redirect handler with the session info and authenticated fetch', async () => { 57 | const redirectHandler = mockRedirectHandler(); 58 | const sessionInfo = { 59 | isLoggedIn: true, 60 | sessionID: 'session-id', 61 | fetch: jest.fn(), 62 | }; 63 | (redirectHandler.handle as jest.Mock).mockResolvedValue(sessionInfo); 64 | 65 | const afterRedirect = jest.fn(); 66 | 67 | const redirector = new ChromeExtensionRedirector( 68 | redirectHandler, 69 | afterRedirect 70 | ); 71 | redirector.redirect('/redirect-url'); 72 | 73 | const returnToExtension = (launchWebAuthFlow as jest.Mock).mock 74 | .calls[0][1]; 75 | 76 | await returnToExtension('/redirect-url?code=123'); 77 | 78 | expect(afterRedirect).toHaveBeenCalledWith(sessionInfo); 79 | }); 80 | 81 | it('calls the after redirect handler with error from authentication process', async () => { 82 | const redirectHandler = mockRedirectHandler(); 83 | 84 | const afterRedirect = jest.fn(); 85 | 86 | const redirector = new ChromeExtensionRedirector( 87 | redirectHandler, 88 | afterRedirect 89 | ); 90 | redirector.redirect('/redirect-url'); 91 | 92 | const returnToExtension = (launchWebAuthFlow as jest.Mock).mock 93 | .calls[0][1]; 94 | 95 | await returnToExtension( 96 | '/redirect-url?code=123', 97 | new Error('error during authentication') 98 | ); 99 | 100 | expect(afterRedirect).toHaveBeenCalledWith( 101 | { 102 | isLoggedIn: false, 103 | sessionId: '', 104 | fetch: undefined, 105 | }, 106 | new Error('error during authentication') 107 | ); 108 | }); 109 | 110 | it('calls the after redirect handler with an error from redirect handler', async () => { 111 | const redirectHandler = mockRedirectHandler(); 112 | 113 | (redirectHandler.handle as jest.Mock).mockRejectedValue( 114 | new Error('error during handler') 115 | ); 116 | 117 | const afterRedirect = jest.fn(); 118 | 119 | const redirector = new ChromeExtensionRedirector( 120 | redirectHandler, 121 | afterRedirect 122 | ); 123 | redirector.redirect('/redirect-url'); 124 | 125 | const returnToExtension = (launchWebAuthFlow as jest.Mock).mock 126 | .calls[0][1]; 127 | 128 | await returnToExtension('/redirect-url?code=123'); 129 | 130 | expect(afterRedirect).toHaveBeenCalledWith( 131 | { 132 | isLoggedIn: false, 133 | sessionId: '', 134 | fetch: undefined, 135 | }, 136 | new Error('error during handler') 137 | ); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/options/choose-storage/ChooseStorage.spec.tsx: -------------------------------------------------------------------------------- 1 | import userEvent from '@testing-library/user-event'; 2 | import { when } from 'jest-when'; 3 | import React from 'react'; 4 | import { act, render, screen } from '@testing-library/react'; 5 | import { ChooseStorage } from './ChooseStorage'; 6 | import { useChooseStorage } from './useChooseStorage'; 7 | 8 | jest.mock('./useChooseStorage'); 9 | 10 | describe('ChooseStorage', () => { 11 | describe('while loading', () => { 12 | beforeEach(() => { 13 | when(useChooseStorage).mockReturnValue({ 14 | loading: true, 15 | submitting: false, 16 | containerUrl: '', 17 | manualChanges: false, 18 | setContainerUrl: jest.fn(), 19 | submit: () => null, 20 | }); 21 | }); 22 | 23 | it('indicates loading', () => { 24 | render(); 25 | expect( 26 | screen.getByText("Let's find a storage location for your clips.") 27 | ).toBeInTheDocument(); 28 | expect(screen.getByText('Please wait...')).toBeInTheDocument(); 29 | }); 30 | }); 31 | 32 | describe('after loading and finding a storage', () => { 33 | let setContainerUrl: jest.Mock; 34 | beforeEach(() => { 35 | setContainerUrl = jest.fn(); 36 | when(useChooseStorage).mockReturnValue({ 37 | loading: false, 38 | submitting: false, 39 | manualChanges: false, 40 | containerUrl: 'https://pod.example/alice/webclip/', 41 | setContainerUrl, 42 | submit: () => null, 43 | }); 44 | }); 45 | 46 | it('shows the container url', () => { 47 | render(); 48 | expect( 49 | screen.getByDisplayValue('https://pod.example/alice/webclip/') 50 | ).toBeInTheDocument(); 51 | }); 52 | 53 | it('allows to change the proposed url', () => { 54 | render(); 55 | const input = screen.getByLabelText('Storage Location'); 56 | act(() => { 57 | userEvent.paste(input, 'additional-container'); 58 | }); 59 | expect(setContainerUrl).toHaveBeenLastCalledWith( 60 | 'https://pod.example/alice/webclip/additional-container' 61 | ); 62 | }); 63 | 64 | it('asks if it is ok', () => { 65 | render(); 66 | expect( 67 | screen.getByText( 68 | 'WebClip is going to store data at the following location. Confirm, if you are fine with that, or enter the URL of a different location.' 69 | ) 70 | ).toBeInTheDocument(); 71 | }); 72 | 73 | it('allows to confirm', () => { 74 | render(); 75 | const continueButton = screen.getByText('Confirm'); 76 | expect(continueButton).toBeInTheDocument(); 77 | userEvent.click(continueButton); 78 | }); 79 | }); 80 | 81 | describe('after loading and finding no storage', () => { 82 | let setContainerUrl: jest.Mock; 83 | let submit: jest.Mock; 84 | beforeEach(() => { 85 | submit = jest.fn(); 86 | setContainerUrl = jest.fn(); 87 | when(useChooseStorage).mockReturnValue({ 88 | loading: false, 89 | submitting: false, 90 | containerUrl: null, 91 | manualChanges: true, 92 | setContainerUrl, 93 | submit, 94 | }); 95 | }); 96 | 97 | it('asks to enter a url', () => { 98 | render(); 99 | expect( 100 | screen.getByText( 101 | 'WebClip could not find a storage associated with your Pod, please enter a URL manually.' 102 | ) 103 | ).toBeInTheDocument(); 104 | }); 105 | 106 | it('allows to enter a url manually', () => { 107 | render(); 108 | const input = screen.getByLabelText('Storage Location'); 109 | act(() => { 110 | userEvent.paste(input, 'https://pod.example/alice/webclip/'); 111 | }); 112 | expect(setContainerUrl).toHaveBeenLastCalledWith( 113 | 'https://pod.example/alice/webclip/' 114 | ); 115 | }); 116 | 117 | it('allows to submit', () => { 118 | render(); 119 | const continueButton = screen.getByText('Submit'); 120 | expect(continueButton).toBeInTheDocument(); 121 | userEvent.click(continueButton); 122 | expect(submit).toHaveBeenCalled(); 123 | }); 124 | }); 125 | 126 | describe('after submitting an invalid container url', () => { 127 | beforeEach(() => { 128 | when(useChooseStorage).mockReturnValue({ 129 | loading: false, 130 | submitting: false, 131 | manualChanges: false, 132 | containerUrl: 'https://pod.example/alice/webclip/', 133 | setContainerUrl: () => null, 134 | validationError: new Error('Please choose a valid container'), 135 | submit: () => null, 136 | }); 137 | }); 138 | it('shows the validation error', () => { 139 | render(); 140 | expect( 141 | screen.getByText('Please choose a valid container') 142 | ).toBeInTheDocument(); 143 | }); 144 | }); 145 | }); 146 | --------------------------------------------------------------------------------