Skip to content

Commit 85ac323

Browse files
authored
Merge pull request #14 from solid/add-chat-logic
add chat logic for individual chats and tests for it
2 parents de9dca2 + 07d0a9a commit 85ac323

File tree

14 files changed

+9430
-259
lines changed

14 files changed

+9430
-259
lines changed

jest.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
module.exports = {
2-
verbose: true
2+
verbose: true,
3+
setupFilesAfterEnv: ["./jest.setup.ts"],
34
}

jest.setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import fetchMock from "jest-fetch-mock";
2+
fetchMock.enableMocks();

package-lock.json

Lines changed: 8572 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "solid-logic",
3-
"version": "1.2.9",
3+
"version": "1.3.0",
44
"description": "Core business logic of Solid OS",
55
"main": "lib/index.js",
66
"scripts": {
@@ -29,6 +29,7 @@
2929
"@babel/preset-env": "7.12.11",
3030
"@babel/preset-typescript": "7.12.7",
3131
"jest": "26.6.3",
32+
"jest-fetch-mock": "^3.0.3",
3233
"ts-jest": "26.4.4",
3334
"typescript": "4.1.3"
3435
},

src/authn/NoAuthnLogic.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {sym} from "rdflib";
2+
import {AuthnLogic, Session} from "./index";
3+
4+
/**
5+
* Fallback, if no auth client has been provided to solid-logic
6+
*/
7+
export class NoAuthnLogic implements AuthnLogic {
8+
9+
constructor() {
10+
console.warn('no auth client passed to solid-logic, logic that relies on auth is not available')
11+
}
12+
13+
currentUser() {
14+
return null;
15+
}
16+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {sym} from "rdflib";
2+
import {AuthnLogic, Session} from "./index";
3+
4+
/**
5+
* Implements AuthnLogic relying on solid-auth-client
6+
*/
7+
export class SolidAuthClientAuthnLogic implements AuthnLogic {
8+
private session?: Session;
9+
10+
constructor(solidAuthClient) {
11+
solidAuthClient.trackSession(session => {
12+
this.session = session
13+
})
14+
}
15+
16+
currentUser() {
17+
return this.session?.webId ? sym(this.session?.webId) : null
18+
}
19+
}

src/authn/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {NamedNode} from "rdflib";
2+
3+
export interface Session {
4+
webId: string
5+
}
6+
7+
export interface AuthnLogic {
8+
currentUser: () => NamedNode | null
9+
}

src/chat/ChatLogic.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import {NamedNode, st} from 'rdflib'
2+
import {LiveStore} from "../index";
3+
import {newThing} from "../uri";
4+
import {ProfileLogic} from "../profile/ProfileLogic";
5+
import {determineChatContainer} from "./determineChatContainer";
6+
7+
8+
const CHAT_LOCATION_IN_CONTAINER = 'index.ttl#this'
9+
10+
/**
11+
* Chat-related logic
12+
*/
13+
export class ChatLogic {
14+
15+
store: LiveStore
16+
ns: any
17+
profile: ProfileLogic
18+
19+
constructor(store, ns, profile) {
20+
this.store = store
21+
this.ns = ns
22+
this.profile = profile
23+
}
24+
25+
async setAcl(chatContainer, me, invitee) {
26+
// Some servers don't present a Link http response header
27+
// if the container doesn't exist yet, so refetch the container
28+
// now that it has been created:
29+
await this.store.fetcher.load(chatContainer)
30+
31+
// FIXME: check the Why value on this quad:
32+
const chatAclDoc = this.store.any(chatContainer, new NamedNode('http://www.iana.org/assignments/link-relations/acl'))
33+
if (!chatAclDoc) {
34+
throw new Error('Chat ACL doc not found!')
35+
}
36+
37+
const aclBody = `
38+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
39+
<#owner>
40+
a acl:Authorization;
41+
acl:agent <${me.value}>;
42+
acl:accessTo <.>;
43+
acl:default <.>;
44+
acl:mode
45+
acl:Read, acl:Write, acl:Control.
46+
<#invitee>
47+
a acl:Authorization;
48+
acl:agent <${invitee.value}>;
49+
acl:accessTo <.>;
50+
acl:default <.>;
51+
acl:mode
52+
acl:Read, acl:Append.
53+
`
54+
const aclResponse = await this.store.fetcher.webOperation('PUT', chatAclDoc.value, {
55+
data: aclBody,
56+
contentType: 'text/turtle'
57+
})
58+
}
59+
60+
61+
62+
private async addToPrivateTypeIndex(chatThing, me) {
63+
// Add to private type index
64+
const privateTypeIndex = this.store.any(me, this.ns.solid('privateTypeIndex')) as NamedNode | null
65+
if (!privateTypeIndex) {
66+
throw new Error('Private type index not found!')
67+
}
68+
await this.store.fetcher.load(privateTypeIndex)
69+
const reg = newThing(privateTypeIndex)
70+
const ins = [
71+
st(reg, this.ns.rdf('type'), this.ns.solid('TypeRegistration'), privateTypeIndex.doc()),
72+
st(reg, this.ns.solid('forClass'), this.ns.meeting('LongChat'), privateTypeIndex.doc()),
73+
st(reg, this.ns.solid('instance'), chatThing, privateTypeIndex.doc())
74+
]
75+
await new Promise((resolve, reject) => {
76+
this.store.updater.update([], ins, function (_uri, ok, errm) {
77+
if (!ok) {
78+
reject(new Error(errm))
79+
} else {
80+
resolve(null)
81+
}
82+
})
83+
})
84+
}
85+
86+
private async findChat(invitee: NamedNode) {
87+
const me = await this.profile.loadMe()
88+
const podRoot = await this.profile.getPodRoot(me)
89+
const chatContainer = determineChatContainer(invitee, podRoot)
90+
let exists = true
91+
try {
92+
await this.store.fetcher.load(new NamedNode(chatContainer.value + "index.ttl#this"))
93+
} catch (e) {
94+
exists = false
95+
}
96+
return {me, chatContainer, exists}
97+
}
98+
99+
private async createChatThing(chatContainer, me) {
100+
const created : any = await this.mintNew(
101+
{
102+
me,
103+
newBase: chatContainer.value
104+
})
105+
return created.newInstance
106+
}
107+
108+
private mintNew (newPaneOptions) {
109+
const kb = this.store
110+
var updater = kb.updater
111+
if (newPaneOptions.me && !newPaneOptions.me.uri) {
112+
throw new Error('chat mintNew: Invalid userid ' + newPaneOptions.me)
113+
}
114+
115+
var newInstance = (newPaneOptions.newInstance =
116+
newPaneOptions.newInstance ||
117+
kb.sym(newPaneOptions.newBase + CHAT_LOCATION_IN_CONTAINER))
118+
var newChatDoc = newInstance.doc()
119+
120+
kb.add(newInstance, this.ns.rdf('type'), this.ns.meeting('LongChat'), newChatDoc)
121+
kb.add(newInstance, this.ns.dc('title'), 'Chat channel', newChatDoc)
122+
// @ts-ignore
123+
kb.add(newInstance, this.ns.dc('created'), new Date(Date.now()), newChatDoc)
124+
if (newPaneOptions.me) {
125+
kb.add(newInstance, this.ns.dc('author'), newPaneOptions.me, newChatDoc)
126+
}
127+
128+
return new Promise(function (resolve, reject) {
129+
updater.put(
130+
newChatDoc,
131+
kb.statementsMatching(undefined, undefined, undefined, newChatDoc),
132+
'text/turtle',
133+
function (uri2, ok, message) {
134+
if (ok) {
135+
resolve(newPaneOptions)
136+
} else {
137+
reject(
138+
new Error(
139+
'FAILED to save new chat channel at: ' + uri2 + ' : ' + message
140+
)
141+
)
142+
}
143+
}
144+
)
145+
})
146+
}
147+
148+
/**
149+
* Find (and optionally create) an individual chat between the current user and the given invitee
150+
* @param invitee - The person to chat with
151+
* @param createIfMissing - Whether the chat should be created, if missing
152+
* @returns null if missing, or a node referring to an already existing chat, or the newly created chat
153+
*/
154+
async getChat(invitee: NamedNode, createIfMissing = true): Promise<NamedNode | null> {
155+
const {me, chatContainer, exists} = await this.findChat(invitee)
156+
if (exists) {
157+
return new NamedNode(chatContainer.value + CHAT_LOCATION_IN_CONTAINER)
158+
}
159+
160+
if (createIfMissing) {
161+
const chatThing = await this.createChatThing(chatContainer, me)
162+
await this.sendInvite(invitee, chatThing)
163+
await this.setAcl(chatContainer, me, invitee)
164+
await this.addToPrivateTypeIndex(chatThing, me)
165+
return chatThing
166+
}
167+
return null
168+
}
169+
170+
private async sendInvite(invitee: NamedNode, chatThing: NamedNode) {
171+
await this.store.fetcher.load(invitee.doc())
172+
const inviteeInbox = this.store.any(invitee, this.ns.ldp('inbox'), undefined, invitee.doc())
173+
if (!inviteeInbox) {
174+
throw new Error(`Invitee inbox not found! ${invitee.value}`)
175+
}
176+
const inviteBody = `
177+
<> a <http://www.w3.org/ns/pim/meeting#LongChatInvite> ;
178+
${this.ns.rdf('seeAlso')} <${chatThing.value}> .
179+
`
180+
181+
const inviteResponse = await this.store.fetcher.webOperation('POST', inviteeInbox.value, {
182+
data: inviteBody,
183+
contentType: 'text/turtle'
184+
})
185+
const locationStr = inviteResponse.headers.get('location')
186+
if (!locationStr) {
187+
throw new Error(`Invite sending returned a ${inviteResponse.status}`)
188+
}
189+
}
190+
191+
}

src/chat/determineChatContainer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import {NamedNode} from "rdflib";
2+
3+
export function determineChatContainer(invitee, podRoot) {
4+
// Create chat
5+
// See https://gitter.im/solid/chat-app?at=5f3c800f855be416a23ae74a
6+
const chatContainerStr = new URL(`IndividualChats/${new URL(invitee.value).host}/`, podRoot.value).toString()
7+
return new NamedNode(chatContainerStr)
8+
}

0 commit comments

Comments
 (0)