Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions src/acl/accessControlSubjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function isHttpUrl (value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://')
}

function isBareOriginUrl (value: string): boolean {
export function isOriginUrl (value: string): boolean {
try {
const parsed = new URL(value)
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
Expand Down Expand Up @@ -57,7 +57,7 @@ export async function classifyAccessControlSubject (store: LiveStore, principle:
const subject = sym(principle)
let types = store.findTypeURIs(subject)

if (isBareOriginUrl(principle)) {
if (isOriginUrl(principle)) {
return { kind: 'origin', subjectValue: normalizeOriginUrl(principle) }
}

Expand Down
19 changes: 18 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,27 @@ export { reloadOnIdentityReplaced } from './authSession/identityState'
export { getSuggestedIssuers } from './issuer/issuerLogic'
export { createTypeIndexLogic } from './typeIndex/typeIndexLogic'
export { createDirectoryLogic, DEFAULT_DIRECTORY_CATALOG_URL, DEFAULT_DIRECTORY_SOURCES } from './directory/directoryLogic'
export { createProfileMutationService } from './profile/profileMutationService'
export type { BioMutationPlan, BioRow } from './profile/sections/bio/types'
export type { BioDetails } from './profile/sections/bio/types'
export type { ContactMutationPlan, ContactPointRow, ContactAddressRow, ContactInfo, PointDetails, AddressDetails } from './profile/sections/contactInfo/types'
export type { EducationMutationPlan, EducationRow, EducationDetails } from './profile/sections/education/types'
export type { HeadingMutationPlan, ProfileBasicRow, ProfileDetails } from './profile/sections/heading/types'
export type { LanguageMutationPlan } from './profile/sections/languages/mutations'
export type { LanguageRow, LanguageDetails } from './profile/sections/languages/types'
export type { ProjectMutationPlan, ProjectRow, ProjectDetails } from './profile/sections/projects/types'
export type { ResumeMutationPlan } from './profile/sections/resume/mutations'
export type { ResumeRow, RoleDetails, ResumeDetails } from './profile/sections/resume/types'
export type { SkillMutationPlan } from './profile/sections/skills/mutations'
export type { SkillRow, SkillDetails } from './profile/sections/skills/types'
export type { SocialMutationPlan, SocialRow, SocialPresentation, Account, SocialAccountOption } from './profile/sections/social/types'
export type { MutationOps, RowStatus, RdfStatement } from './profile/sections/shared/types'
export { isOriginUrl } from './acl/accessControlSubjects'
export type { AccessControlSubjectKind, AccessControlSubject } from './acl/accessControlSubjects'
export type { ACLContext, AccessMode, AccessSubject, Authorization, PatchPlan } from './types'
export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ChatLogic, DirectoryLogic, DirectoryEntry, DirectorySearchOptions, DirectoryRelationshipLabel, DirectorySource } from './types'
export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ProfileLogic, ChatLogic, DirectoryLogic, DirectoryEntry, DirectorySearchOptions, DirectoryRelationshipLabel, DirectorySource, ProfileMutationService } from './types'
export { UnauthorizedError, CrossOriginForbiddenError, SameOriginForbiddenError, NotFoundError, FetchError, NotEditableError, WebOperationError } from './logic/CustomError'
export { documentDirectoryUri, isAbsoluteHttpUri } from './util/utils'

export {
solidLogicSingleton, // solidLogicSingleton is exported entirely because it is used in solid-panes
Expand Down
62 changes: 62 additions & 0 deletions src/profile/ontology/social/accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export type SocialAccountOntology = {
classUri: string
label: string
icon?: string
homepage?: string
userProfilePrefix?: string
}

const socialMediaOntology = 'https://solidos.github.io/profile-pane/src/ontology/socialMedia.ttl#'

function account (
name: string,
label: string,
icon?: string,
homepage?: string,
userProfilePrefix?: string
): SocialAccountOntology {
return {
classUri: `${socialMediaOntology}${name}`,
label,
icon,
homepage,
userProfilePrefix
}
}

export const socialAccountOntologies: readonly SocialAccountOntology[] = [
account('BlueSkyAccount', 'Bluesky', 'https://solidos.github.io/solid-ui/src/icons/social/bluesky-1.svg', 'https://bsky.app/', 'https://bsky.app/profile/'),
account('DribbleAccount', 'Dribble', 'urn:profile-pane:icon:dribbble', 'https://dribbble.com/', 'https://dribbble.com/'),
account('DiscordAccount', 'Discord', 'urn:profile-pane:icon:discord', 'https://discord.com/', 'https://discord.com/users/'),
account('DiggAccount', 'Digg', 'https://solidos.github.io/solid-ui/src/icons/social/digg-icon.svg', 'https://www.digg.com/', 'https://www.digg.com/'),
account('FacebookAccount', 'Facebook', 'urn:profile-pane:icon:facebook', 'https://www.facebook.com/', 'https://www.facebook.com/'),
account('GithubAccount', 'Github', 'https://solidos.github.io/solid-ui/src/icons/social/github-icon.svg', 'https://github.com/', 'https://www.github.com/'),
account('InstagramAccount', 'Instagram', 'urn:profile-pane:icon:instagram', 'https://www.instagram.com/', 'https://www.instagram.com/'),
account('LinkedInAccount', 'LinkedIn', 'urn:profile-pane:icon:linkedin', 'https://linkedin.com/', 'https://www.linkedin.com/in/'),
account('MastodonAccount', 'Mastodon', 'https://solidos.github.io/solid-ui/src/icons/social/mastodon-2.svg', 'https://joinmastodon.org/', 'https://mastodon.social/'),
account('MatrixAccount', 'Matrix', 'https://solidos.github.io/solid-ui/src/icons/social/matrix-logo-black.svg', 'https://matrix.org/', 'https://matrix.to/#/'),
account('MediumAccount', 'Medium', 'https://solidos.github.io/solid-ui/src/icons/social/medium-logo-wordmark-black.svg', 'https://medium.com/', 'https://medium.com/'),
account('NostrAccount', 'Nostr', 'https://solidos.github.io/solid-ui/src/icons/social/nostr-icon-purple-on-white.svg', 'https://nostr.net/', 'https://primal.net/p/'),
account('OrcidAccount', 'ORCiD', 'https://solidos.github.io/solid-ui/src/icons/social/ORCID-1.svg', 'https://orcid.org/', 'https://orcid.org/'),
account('PinterestAccount', 'Pinterest', 'urn:profile-pane:icon:pinterest', 'https://pinterest.com/', 'https://pin.it/'),
account('RedditAccount', 'Reddit', 'https://solidos.github.io/solid-ui/src/icons/social/reddit-4.svg', 'https://reddit.com/', 'https://www.reddit.com/user/'),
account('SharechatAccount', 'ShareChat', 'urn:profile-pane:icon:sharechat', 'https://sharechat.com/', 'https://www.sharechat.com/user/'),
account('SnapchatAccount', 'Snapchat', 'urn:profile-pane:icon:snapchat', 'https://www.snapchat.com/', 'https://www.snapchat.com/add/'),
account('SpotifyAccount', 'Spotify', 'urn:profile-pane:icon:spotify', 'https://www.spotify.com/', 'https://www.spotify.com/user/'),
account('StravaAccount', 'Strava', 'https://solidos.github.io/solid-ui/src/icons/social/strava-2.svg', 'https://strava.com/', 'https://www.strava.com/athletes/'),
account('TelegramAccount', 'Telegram', 'urn:profile-pane:icon:telegram', 'https://telegram.org/', 'https://t.me/'),
account('TiktokAccount', 'TikTok', 'urn:profile-pane:icon:tiktok', 'https://www.tiktok.com/', 'https://www.tiktok.com/@'),
account('TumblrAccount', 'Tumblr', 'https://solidos.github.io/solid-ui/src/icons/social/tumblr-icon.svg', 'https://www.tumblr.com/', 'https://www.tumblr.com/'),
account('TwitterAccount', 'X', 'urn:profile-pane:icon:x', 'https://x.com/', 'https://x.com/'),
account('WhatsAppAccount', 'WhatsApp', 'urn:profile-pane:icon:whatsapp', 'https://www.whatsapp.com/', 'https://wa.me/'),
account('YouTubeAccount', 'YouTube', 'urn:profile-pane:icon:youtube', 'https://www.youtube.com/', 'https://www.youtube.com/'),
account('OtherAccount', 'Other', 'urn:profile-pane:starIcon')
]

const socialAccountOntologyByClassUri = new Map(
socialAccountOntologies.map(ontology => [ontology.classUri, ontology])
)

export function socialAccountOntologyFor (classUri: string): SocialAccountOntology | undefined {
return socialAccountOntologyByClassUri.get(classUri)
}
73 changes: 31 additions & 42 deletions src/profile/profileLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,72 +2,50 @@ import { literal, NamedNode, st, sym } from 'rdflib'
import { ACL_LINK } from '../acl/aclLogic'
import { CrossOriginForbiddenError, FetchError, NotEditableError, SameOriginForbiddenError, UnauthorizedError, WebOperationError } from '../logic/CustomError'
import * as debug from '../util/debug'
import { ns as namespace } from '../util/ns'
import { ns } from '../util/ns'
import { privateTypeIndexDocument, publicTypeIndexDocument } from '../typeIndex/typeIndexDocuments'
import { publicTypeIndexAclDocument } from '../typeIndex/typeIndexAclDocuments'
import { preferencesFileDocument } from './profileDocuments'
import { ownerOnlyContainerAclDocument } from './profileAclDocuments'
import { createContainerLogic } from '../util/containerLogic'
import { differentOrigin, suggestPreferencesFile } from '../util/utils'
import { differentOrigin, documentDirectoryUri, suggestPreferencesFile } from '../util/utils'
import { isMissingError } from '../resource/resourceHttp'
import { ProfileLogic } from '../types'
import { createProfileMutationService } from './profileMutationService'
import { getBio } from './sections/bio/selectors'
import { getContactInfo } from './sections/contactInfo/selectors'
import { getEducation } from './sections/education/selectors'
import { presentLanguages } from './sections/languages/selectors'
import { getSkillDetails, getSkills } from './sections/skills/selectors'
import { getHeading } from './sections/heading/selectors'
import { presentProjects } from './sections/projects/selectors'
import { presentCV } from './sections/resume/selectors'
import { getSocialAccounts } from './sections/social/read'

export function createProfileLogic(store, authn, utilityLogic): ProfileLogic {
const ns = namespace
const containerLogic = createContainerLogic(store)
const profileMutationService = createProfileMutationService(store)
const loadPreferencesInFlight = new Map<string, Promise<NamedNode>>()
const cachedPreferencesFileByWebId = new Map<string, NamedNode>()

function isAbsoluteHttpUri(uri: string | null | undefined): boolean {
return !!uri && (uri.startsWith('https://') || uri.startsWith('http://'))
}

function docDirUri(node: NamedNode): string | null {
const doc = node.doc()
const dir = doc.dir()
if (dir?.uri && isAbsoluteHttpUri(dir.uri)) return dir.uri
const docUri = doc.uri
if (!docUri || !isAbsoluteHttpUri(docUri)) return null
const withoutFragment = docUri.split('#')[0]
const lastSlash = withoutFragment.lastIndexOf('/')
if (lastSlash === -1) return null
return withoutFragment.slice(0, lastSlash + 1)
}

function suggestTypeIndexInPreferences(preferencesFile: NamedNode, filename: string): NamedNode {
const dirUri = docDirUri(preferencesFile)
const dirUri = documentDirectoryUri(preferencesFile)
if (!dirUri) throw new Error(`Cannot derive directory for preferences file ${preferencesFile.uri}`)
return sym(dirUri + filename)
}

function isNotFoundError(err: any): boolean {
if (err?.response?.status === 404) return true
const text = `${err?.message || err || ''}`
return text.includes('404') || text.includes('Not Found')
}

async function ensureContainerExists(containerUri: string): Promise<void> {
const containerNode = sym(containerUri)
try {
await store.fetcher.load(containerNode)
return
} catch (err) {
if (!isNotFoundError(err)) throw err
}
await containerLogic.createContainer(containerUri)
}

async function ensureOwnerOnlyAclForSettings(user: NamedNode, preferencesFile: NamedNode): Promise<void> {
const dirUri = docDirUri(preferencesFile)
const dirUri = documentDirectoryUri(preferencesFile)
if (!dirUri) throw new Error(`Cannot derive settings directory from ${preferencesFile.uri}`)
await ensureContainerExists(dirUri)
await containerLogic.ensureContainer(dirUri)

const containerNode = sym(dirUri)
let aclDocUri: string | undefined
try {
await store.fetcher.load(containerNode)
aclDocUri = store.any(containerNode, ACL_LINK)?.value
} catch (err) {
if (!isNotFoundError(err)) throw err
if (!isMissingError(err)) throw err
}
if (!aclDocUri) {
// Fallback for servers/tests where rel=acl is not exposed in mocked headers.
Expand All @@ -78,7 +56,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic {
await store.fetcher.load(aclDoc)
return
} catch (err) {
if (!isNotFoundError(err)) throw err
if (!isMissingError(err)) throw err
}

await store.fetcher.webOperation('PUT', aclDoc.uri, {
Expand All @@ -96,7 +74,7 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic {
await store.fetcher.load(publicTypeIndex)
aclDocUri = store.any(publicTypeIndex, ACL_LINK)?.value
} catch (err) {
if (!isNotFoundError(err)) throw err
if (!isMissingError(err)) throw err
}
if (!aclDocUri) {
aclDocUri = `${publicTypeIndex.uri}.acl`
Expand Down Expand Up @@ -336,6 +314,17 @@ export function createProfileLogic(store, authn, utilityLogic): ProfileLogic {
}

return {
...profileMutationService,
getBio: (subject: NamedNode) => getBio(subject, store),
getContactInfo: (subject: NamedNode) => getContactInfo(subject, store),
getEducation: (subject: NamedNode) => getEducation(subject, store),
getLanguages: (subject: NamedNode) => presentLanguages(subject, store),
getSkillDetails: (subject: NamedNode) => getSkillDetails(subject, store),
getSkills: (subject: NamedNode) => getSkills(subject, store),
getHeading: (subject: NamedNode) => getHeading(subject, store),
getResume: (subject: NamedNode) => presentCV(subject, store),
getProjects: (subject: NamedNode) => presentProjects(subject, store),
getSocial: (subject: NamedNode) => getSocialAccounts(subject, store),
loadMe,
getPodRoot,
getMainInbox,
Expand Down
63 changes: 63 additions & 0 deletions src/profile/profileMutationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { NamedNode, LiveStore } from 'rdflib'
import { NotEditableError } from '../logic/CustomError'
import type { ProfileMutationService } from '../types'
import { processBioMutations } from './sections/bio/mutations'
import { processContactInfoMutations } from './sections/contactInfo/mutations'
import { processEducationMutations } from './sections/education/mutations'
import { processHeadingMutations } from './sections/heading/mutations'
import { processLanguageMutations } from './sections/languages/mutations'
import { processProjectsMutations } from './sections/projects/mutations'
import { processResumeMutations } from './sections/resume/mutations'
import { processSkillsMutations } from './sections/skills/mutations'
import { processSocialMutations } from './sections/social/mutations'
import type { HeadingMutationPlan } from './sections/heading/types'
import type { BioMutationPlan } from './sections/bio/types'
import type { ContactMutationPlan } from './sections/contactInfo/types'
import type { EducationMutationPlan } from './sections/education/types'
import type { LanguageMutationPlan } from './sections/languages/mutations'
import type { LanguageRow } from './sections/languages/types'
import type { ProjectMutationPlan } from './sections/projects/types'
import type { ResumeMutationPlan } from './sections/resume/mutations'
import type { SkillMutationPlan } from './sections/skills/mutations'
import type { SocialMutationPlan, SocialRow } from './sections/social/types'

export function createProfileMutationService (store: LiveStore): ProfileMutationService {
async function prepareProfileDocument (doc: NamedNode): Promise<void> {
await store.fetcher.load(doc, { force: true })
if (!store.updater.editable(doc)) {
throw new NotEditableError(`Cannot edit profile document ${doc.value}`)
}
}

async function runProfileSectionMutation<Args extends unknown[]>(
subject: NamedNode,
mutation: (store: LiveStore, subject: NamedNode, ...args: Args) => Promise<void>,
...args: Args
): Promise<void> {
await prepareProfileDocument(subject.doc())
await mutation(store, subject, ...args)
}

return {
processHeadingMutations: (subject: NamedNode, plan: HeadingMutationPlan) =>
runProfileSectionMutation(subject, processHeadingMutations, plan),
processContactInfoMutations: (subject: NamedNode, plan: ContactMutationPlan) =>
runProfileSectionMutation(subject, processContactInfoMutations, plan),
processBioMutations: (subject: NamedNode, plan: BioMutationPlan) =>
runProfileSectionMutation(subject, processBioMutations, plan),
processEducationMutations: (subject: NamedNode, plan: EducationMutationPlan) =>
runProfileSectionMutation(subject, processEducationMutations, plan),
processLanguageMutations: (subject: NamedNode, plan: LanguageMutationPlan, orderedRows?: LanguageRow[]) =>
runProfileSectionMutation(subject, processLanguageMutations, plan, orderedRows),
processProjectsMutations: (subject: NamedNode, plan: ProjectMutationPlan) =>
runProfileSectionMutation(subject, processProjectsMutations, plan),
processResumeMutations: (subject: NamedNode, plan: ResumeMutationPlan) =>
runProfileSectionMutation(subject, processResumeMutations, plan),
processSkillsMutations: (subject: NamedNode, plan: SkillMutationPlan) =>
runProfileSectionMutation(subject, processSkillsMutations, plan),
processSocialMutations: (subject: NamedNode, plan: SocialMutationPlan, orderedRows?: SocialRow[]) =>
runProfileSectionMutation(subject, processSocialMutations, plan, orderedRows)
}
}

export type { ProfileMutationService }
Loading
Loading