S2S: Component: Implement Add-based version of inviteComponent

This commit is contained in:
Pere Lev 2024-05-11 22:25:50 +03:00
parent 38ce72996c
commit f864274ff0
No known key found for this signature in database
GPG key ID: 5252C5C863E5E57D
15 changed files with 653 additions and 255 deletions

View file

@ -1891,9 +1891,8 @@ componentGrant grabResource topicComponent now recipKey (Verse authorIdMsig body
-- Meaning: An actor is adding some object to some target -- Meaning: An actor is adding some object to some target
-- Behavior: -- Behavior:
-- * If the object is me: -- * If target is my context (i.e. parents) collection:
-- * Verify that the object is me -- * Verify the object is a project
-- * Verify the target is some project's components collection URI
-- * Verify the Add is authorized -- * Verify the Add is authorized
-- * For all the Stem records I have for this project: -- * For all the Stem records I have for this project:
-- * Verify I'm not yet a member of the project -- * Verify I'm not yet a member of the project
@ -1911,6 +1910,19 @@ componentGrant grabResource topicComponent now recipKey (Verse authorIdMsig body
-- * Author's followers -- * Author's followers
-- * Project's followers -- * Project's followers
-- * My followers -- * My followers
--
-- * If the object is me:
-- * Verify the target is some project's components collection URI
-- * For each Stem record I have for this project:
-- * Verify it's not enabled yet, i.e. I'm not already a component
-- of this project
-- * Verify it's not in them-Invite-Accept state, already got the
-- project's Accept and waiting for my approval
-- * Verify it's not in us-Add-Accept state, has my approval and
-- waiting for the project's side
-- * Create a Stem record in DB
-- * Insert the Add to my inbox
-- * Forward the Add to my followers
componentAdd componentAdd
:: (PersistRecordBackend topic SqlBackend, ToBackendKey SqlBackend topic) :: (PersistRecordBackend topic SqlBackend, ToBackendKey SqlBackend topic)
=> (topic -> KomponentId) => (topic -> KomponentId)
@ -1922,183 +1934,293 @@ componentAdd
-> ActE (Text, Act (), Next) -> ActE (Text, Act (), Next)
componentAdd grabKomponent toComponent now meID (Verse authorIdMsig body) add = do componentAdd grabKomponent toComponent now meID (Verse authorIdMsig body) add = do
let meComponent = toComponent meID let author = bimap (view _1) (remoteAuthorURI . view _1) authorIdMsig
meResource = componentResource meComponent (object, target, role) <- parseAdd author add
meActor = resourceToActor meResource unless (role == AP.RoleAdmin) $
throwE "Add role isn't admin"
-- Check capability case (target, object) of
capability <- do (Left at, _)
| addTargetComponentProjects at == Just (toComponent meID) -> do
-- Verify that a capability is provided
uCap <- do
let muCap = AP.activityCapability $ actbActivity body
fromMaybeE muCap "No capability provided"
-- Verify the capability URI is one of:
-- * Outbox item URI of a local actor, i.e. a local activity
-- * A remote URI
cap <- nameExceptT "Add capability" $ parseActivityURI' uCap
-- Verify the capability is local
case cap of
Left (actorByKey, _, outboxItemID) ->
return (actorByKey, outboxItemID)
_ -> throwE "Capability is remote i.e. definitely not by me"
-- Check input
projectComps <- do
let author = bimap (view _1) (remoteAuthorURI . view _1) authorIdMsig
(component, projectComps, role) <- parseAdd author add
unless (component == Left meActor) $
throwE "Add object isn't me"
unless (role == AP.RoleAdmin) $
throwE "Add role isn't admin"
case projectComps of
Left (ATProjectComponents j) -> return $ Left j
Right u -> return $ Right u
_ -> throwE "I'm being added somewhere invalid"
-- If project is local, find it in our DB
-- If project is remote, HTTP GET it and store in our DB (if it's already
-- there, no need for HTTP)
--
-- NOTE: This is a blocking HTTP GET done right here in the handler,
-- which is NOT a good idea. Ideally, it would be done async, and the
-- handler result would be sent later in a separate (e.g. Accept) activity.
-- But for the PoC level, the current situation will hopefully do.
projectDB <-
bitraverse
(withDBExcept . flip getEntityE "Project not found in DB")
(\ u@(ObjURI h luComps) -> do
manager <- asksEnv envHttpManager
collection <-
ExceptT $ first T.pack <$>
AP.fetchAPID
manager
(AP.collectionId :: AP.Collection FedURI URIMode -> LocalURI)
h
luComps
luProject <- fromMaybeE (AP.collectionContext collection) "Collection has no context"
project <- project <-
ExceptT $ first T.pack <$> bitraverse
AP.fetchAPID manager (AP.actorId . AP.actorLocal . AP.projectActor) h luProject (\case
unless (AP.projectComponents project == luComps) $ LocalActorProject j -> pure j
throwE "The collection isn't the project's components collection" _ -> throwE "Adding me to a local non-project"
)
instanceID <- pure
lift $ withDB $ either entityKey id <$> insertBy' (Instance h) object
result <- addProjectActive role project
ExceptT $ first (T.pack . displayException) <$> (_, Left la)
fetchRemoteActor' instanceID h luProject | resourceToActor (componentResource $ toComponent meID) == la -> do
case result of case target of
Left Nothing -> throwE "Target @id mismatch" Left (ATProjectComponents j) ->
Left (Just err) -> throwE $ T.pack $ displayException err addProjectPassive role $ Left j
Right Nothing -> throwE "Target isn't an actor" Right (ObjURI h luColl) -> do
Right (Just actor) -> do -- NOTE this is HTTP GET done synchronously in the activity
unless (remoteActorType (entityVal actor) == AP.ActorTypeProject) $ -- handler
throwE "Remote project type isn't Project" manager <- asksEnv envHttpManager
return $ entityKey actor c <- AP.fetchAPID_T manager (AP.collectionId :: AP.Collection FedURI URIMode -> LocalURI) h luColl
) lu <- fromMaybeE (AP.collectionContext c) "No context"
projectComps j <- AP.fetchAPID_T manager (AP.actorId . AP.actorLocal . AP.projectActor) h lu
if luColl == AP.projectComponents j
meHash <- encodeKeyHashid meID then addProjectPassive role $ Right $ ObjURI h lu
let meComponentHash = toComponent meHash else throwE "Non-components collection"
meResourceHash = componentResource meComponentHash _ -> throwE "I'm being added somewhere irrelevant"
meActorHash = resourceToActor meResourceHash _ -> throwE "This Add isn't for me"
maybeNew <- withDBExcept $ do
-- Grab me from DB
komponentID <- lift $ grabKomponent <$> getJust meID
Komponent resourceID <- lift $ getJust komponentID
Resource meActorID <- lift $ getJust resourceID
actor <- lift $ getJust meActorID
-- Find existing Stem records I have for this project
-- Make sure none are enabled / in Add-Accept mode / in Invite-Accept
-- mode
checkExistingStems komponentID projectDB
-- Verify the specified capability gives relevant access
verifyCapability' capability authorIdMsig meResource AP.RoleAdmin
-- Insert the Add to my inbox
mractid <- lift $ insertToInbox now authorIdMsig body (actorInbox actor) False
lift $ for mractid $ \ (inboxItemID, addDB) -> do
-- Create a Stem record in DB
acceptID <- insertEmptyOutboxItem' (actorOutbox actor) now
insertStem komponentID projectDB addDB acceptID
-- Prepare forwarding Add to my followers
let sieve = makeRecipientSet [] [localActorFollowers meActorHash]
-- Prepare an Accept activity and insert to my outbox
accept@(actionAccept, _, _, _) <- prepareAccept projectDB
_luAccept <- updateOutboxItem' meActor acceptID actionAccept
return (meActorID, sieve, acceptID, accept, inboxItemID)
case maybeNew of
Nothing -> done "I already have this activity in my inbox"
Just (actorID, sieve, acceptID, (actionAccept, localRecipsAccept, remoteRecipsAccept, fwdHostsAccept), inboxItemID) -> do
forwardActivity authorIdMsig body meActor actorID sieve
lift $ sendActivity
meActor actorID localRecipsAccept
remoteRecipsAccept fwdHostsAccept acceptID actionAccept
doneDB inboxItemID "Recorded and forwarded the Add, sent an Accept"
where where
insertStem komponentID projectDB addDB acceptID = do addProjectActive role project = do
stemID <- insert $ Stem AP.RoleAdmin komponentID
case projectDB of
Left (Entity projectID _) ->
insert_ $ StemProjectLocal stemID projectID
Right remoteActorID ->
insert_ $ StemProjectRemote stemID remoteActorID
insert_ $ StemOriginAdd stemID
case addDB of
Left (_, _, addID) ->
insert_ $ StemComponentGestureLocal stemID addID
Right (author, _, addID) ->
insert_ $ StemComponentGestureRemote stemID (remoteAuthorId author) addID
insert_ $ StemComponentAccept stemID acceptID
prepareAccept projectDB = do let meComponent = toComponent meID
encodeRouteHome <- getEncodeRouteHome meResource = componentResource meComponent
meActor = resourceToActor meResource
audAdder <- makeAudSenderWithFollowers authorIdMsig -- Check capability
audProject <- capability <- do
-- Verify that a capability is provided
uCap <- do
let muCap = AP.activityCapability $ actbActivity body
fromMaybeE muCap "No capability provided"
-- Verify the capability URI is one of:
-- * Outbox item URI of a local actor, i.e. a local activity
-- * A remote URI
cap <- nameExceptT "Add capability" $ parseActivityURI' uCap
-- Verify the capability is local
case cap of
Left (actorByKey, _, outboxItemID) ->
return (actorByKey, outboxItemID)
_ -> throwE "Capability is remote i.e. definitely not by me"
-- Check input
unless (role == AP.RoleAdmin) $ throwE "Add role isn't admin"
-- If project is local, find it in our DB
-- If project is remote, HTTP GET it and store in our DB (if it's already
-- there, no need for HTTP)
--
-- NOTE: This is a blocking HTTP GET done right here in the handler,
-- which is NOT a good idea. Ideally, it would be done async, and the
-- handler result would be sent later in a separate (e.g. Accept) activity.
-- But for the PoC level, the current situation will hopefully do.
projectDB <-
bitraverse
(withDBExcept . flip getEntityE "Project not found in DB")
(\ u@(ObjURI h luProject) -> do
manager <- asksEnv envHttpManager
project <-
ExceptT $ first T.pack <$>
AP.fetchAPID manager (AP.actorId . AP.actorLocal . AP.projectActor) h luProject
instanceID <-
lift $ withDB $ either entityKey id <$> insertBy' (Instance h)
result <-
ExceptT $ first (T.pack . displayException) <$>
fetchRemoteActor' instanceID h luProject
case result of
Left Nothing -> throwE "Target @id mismatch"
Left (Just err) -> throwE $ T.pack $ displayException err
Right Nothing -> throwE "Target isn't an actor"
Right (Just actor) -> do
unless (remoteActorType (entityVal actor) == AP.ActorTypeProject) $
throwE "Remote project type isn't Project"
return $ entityKey actor
)
project
meHash <- encodeKeyHashid meID
let meComponentHash = toComponent meHash
meResourceHash = componentResource meComponentHash
meActorHash = resourceToActor meResourceHash
maybeNew <- withDBExcept $ do
-- Grab me from DB
komponentID <- lift $ grabKomponent <$> getJust meID
Komponent resourceID <- lift $ getJust komponentID
Resource meActorID <- lift $ getJust resourceID
actor <- lift $ getJust meActorID
-- Find existing Stem records I have for this project
-- Make sure none are enabled / in Add-Accept mode / in Invite-Accept
-- mode
checkExistingStems komponentID projectDB
-- Verify the specified capability gives relevant access
verifyCapability' capability authorIdMsig meResource AP.RoleAdmin
-- Insert the Add to my inbox
mractid <- lift $ insertToInbox now authorIdMsig body (actorInbox actor) False
lift $ for mractid $ \ (inboxItemID, addDB) -> do
-- Create a Stem record in DB
acceptID <- insertEmptyOutboxItem' (actorOutbox actor) now
insertStem komponentID projectDB addDB acceptID
-- Prepare forwarding Add to my followers
let sieve = makeRecipientSet [] [localActorFollowers meActorHash]
-- Prepare an Accept activity and insert to my outbox
accept@(actionAccept, _, _, _) <- prepareAccept projectDB
_luAccept <- updateOutboxItem' meActor acceptID actionAccept
return (meActorID, sieve, acceptID, accept, inboxItemID)
case maybeNew of
Nothing -> done "I already have this activity in my inbox"
Just (actorID, sieve, acceptID, (actionAccept, localRecipsAccept, remoteRecipsAccept, fwdHostsAccept), inboxItemID) -> do
forwardActivity authorIdMsig body meActor actorID sieve
lift $ sendActivity
meActor actorID localRecipsAccept
remoteRecipsAccept fwdHostsAccept acceptID actionAccept
doneDB inboxItemID "[Add-project-active] Recorded and forwarded the Add, sent an Accept"
where
insertStem komponentID projectDB addDB acceptID = do
stemID <- insert $ Stem AP.RoleAdmin komponentID
case projectDB of case projectDB of
Left (Entity j _) -> do Left (Entity projectID _) ->
jh <- encodeKeyHashid j insert_ $ StemProjectLocal stemID projectID
return $ Right remoteActorID ->
AudLocal insert_ $ StemProjectRemote stemID remoteActorID
[LocalActorProject jh] insert_ $ StemOriginAdd stemID
[LocalStageProjectFollowers jh] case addDB of
Right remoteActorID -> do Left (_, _, addID) ->
ra <- getJust remoteActorID insert_ $ StemComponentGestureLocal stemID addID
ObjURI h lu <- getRemoteActorURI ra Right (author, _, addID) ->
return $ AudRemote h [lu] (maybeToList $ remoteActorFollowers ra) insert_ $ StemComponentGestureRemote stemID (remoteAuthorId author) addID
audComponent <- insert_ $ StemComponentAccept stemID acceptID
AudLocal [] . pure . localActorFollowers <$>
hashLocalActor (resourceToActor $ componentResource $ toComponent meID)
uAdd <- lift $ getActivityURI authorIdMsig
let (recipientSet, remoteActors, fwdHosts, audLocal, audRemote) = prepareAccept projectDB = do
collectAudience [audAdder, audProject, audComponent] encodeRouteHome <- getEncodeRouteHome
recips = map encodeRouteHome audLocal ++ audRemote audAdder <- makeAudSenderWithFollowers authorIdMsig
action = AP.Action audProject <-
{ AP.actionCapability = Nothing case projectDB of
, AP.actionSummary = Nothing Left (Entity j _) -> do
, AP.actionAudience = AP.Audience recips [] [] [] [] [] jh <- encodeKeyHashid j
, AP.actionFulfills = [uAdd] return $
, AP.actionSpecific = AP.AcceptActivity AP.Accept AudLocal
{ AP.acceptObject = uAdd [LocalActorProject jh]
, AP.acceptResult = Nothing [LocalStageProjectFollowers jh]
Right remoteActorID -> do
ra <- getJust remoteActorID
ObjURI h lu <- getRemoteActorURI ra
return $ AudRemote h [lu] (maybeToList $ remoteActorFollowers ra)
audComponent <-
AudLocal [] . pure . localActorFollowers <$>
hashLocalActor (resourceToActor $ componentResource $ toComponent meID)
uAdd <- lift $ getActivityURI authorIdMsig
let (recipientSet, remoteActors, fwdHosts, audLocal, audRemote) =
collectAudience [audAdder, audProject, audComponent]
recips = map encodeRouteHome audLocal ++ audRemote
action = AP.Action
{ AP.actionCapability = Nothing
, AP.actionSummary = Nothing
, AP.actionAudience = AP.Audience recips [] [] [] [] []
, AP.actionFulfills = [uAdd]
, AP.actionSpecific = AP.AcceptActivity AP.Accept
{ AP.acceptObject = uAdd
, AP.acceptResult = Nothing
}
} }
}
return (action, recipientSet, remoteActors, fwdHosts) return (action, recipientSet, remoteActors, fwdHosts)
addProjectPassive role project = do
let meComponent = toComponent meID
meResource = componentResource meComponent
meActor = resourceToActor meResource
-- Check input
unless (role == AP.RoleAdmin) $ throwE "Add role isn't admin"
-- If project is local, find it in our DB
-- If project is remote, HTTP GET it and store in our DB (if it's already
-- there, no need for HTTP)
--
-- NOTE: This is a blocking HTTP GET done right here in the handler,
-- which is NOT a good idea. Ideally, it would be done async, and the
-- handler result would be sent later in a separate (e.g. Accept) activity.
-- But for the PoC level, the current situation will hopefully do.
projectDB <-
bitraverse
(withDBExcept . flip getEntityE "Project not found in DB")
(\ u@(ObjURI h luProject) -> do
manager <- asksEnv envHttpManager
project <-
ExceptT $ first T.pack <$>
AP.fetchAPID manager (AP.actorId . AP.actorLocal . AP.projectActor) h luProject
instanceID <-
lift $ withDB $ either entityKey id <$> insertBy' (Instance h)
result <-
ExceptT $ first (T.pack . displayException) <$>
fetchRemoteActor' instanceID h luProject
case result of
Left Nothing -> throwE "Target @id mismatch"
Left (Just err) -> throwE $ T.pack $ displayException err
Right Nothing -> throwE "Target isn't an actor"
Right (Just actor) -> do
unless (remoteActorType (entityVal actor) == AP.ActorTypeProject) $
throwE "Remote project type isn't Project"
return $ entityKey actor
)
project
meHash <- encodeKeyHashid meID
let meComponentHash = toComponent meHash
meResourceHash = componentResource meComponentHash
meActorHash = resourceToActor meResourceHash
maybeNew <- withDBExcept $ do
-- Grab me from DB
komponentID <- lift $ grabKomponent <$> getJust meID
Komponent resourceID <- lift $ getJust komponentID
Resource meActorID <- lift $ getJust resourceID
actor <- lift $ getJust meActorID
-- Find existing Stem records I have for this project
-- Make sure none are enabled / in Add-Accept mode / in Invite-Accept
-- mode
checkExistingStems komponentID projectDB
-- Insert the Add to my inbox
mractid <- lift $ insertToInbox now authorIdMsig body (actorInbox actor) False
lift $ for mractid $ \ (inboxItemID, addDB) -> do
-- Create a Stem record in DB
insertStem komponentID projectDB addDB
-- Prepare forwarding Add to my followers
let sieve = makeRecipientSet [] [localActorFollowers meActorHash]
return (meActorID, sieve, inboxItemID)
case maybeNew of
Nothing -> done "I already have this activity in my inbox"
Just (actorID, sieve, inboxItemID) -> do
forwardActivity authorIdMsig body meActor actorID sieve
doneDB inboxItemID "[Add-parent-passive] Recorded and forwarded the Add"
where
insertStem komponentID projectDB addDB = do
stemID <- insert $ Stem AP.RoleAdmin komponentID
case projectDB of
Left (Entity projectID _) ->
insert_ $ StemProjectLocal stemID projectID
Right remoteActorID ->
insert_ $ StemProjectRemote stemID remoteActorID
originID <- insert $ StemOriginInvite stemID
case addDB of
Left (_, _, addID) ->
insert_ $ StemProjectGestureLocal originID addID
Right (author, _, addID) ->
insert_ $ StemProjectGestureRemote originID (remoteAuthorId author) addID

View file

@ -31,6 +31,7 @@ module Vervis.Data.Collab
, parseRemove , parseRemove
, AddTarget (..) , AddTarget (..)
, addTargetResource , addTargetResource
, addTargetComponentProjects
, parseAdd , parseAdd
, ComponentBy (..) , ComponentBy (..)
@ -421,6 +422,9 @@ data AddTarget
| ATProjectChildren ProjectId | ATProjectChildren ProjectId
| ATGroupParents GroupId | ATGroupParents GroupId
| ATGroupChildren GroupId | ATGroupChildren GroupId
| ATRepoProjects RepoId
| ATDeckProjects DeckId
| ATLoomProjects LoomId
deriving Eq deriving Eq
addTargetResource :: AddTarget -> LocalResourceBy Key addTargetResource :: AddTarget -> LocalResourceBy Key
@ -430,6 +434,15 @@ addTargetResource = \case
ATProjectChildren j -> LocalResourceProject j ATProjectChildren j -> LocalResourceProject j
ATGroupParents g -> LocalResourceGroup g ATGroupParents g -> LocalResourceGroup g
ATGroupChildren g -> LocalResourceGroup g ATGroupChildren g -> LocalResourceGroup g
ATRepoProjects r -> LocalResourceRepo r
ATDeckProjects d -> LocalResourceDeck d
ATLoomProjects l -> LocalResourceLoom l
addTargetComponentProjects = \case
ATRepoProjects r -> Just $ ComponentRepo r
ATDeckProjects d -> Just $ ComponentDeck d
ATLoomProjects l -> Just $ ComponentLoom l
_ -> Nothing
parseAdd parseAdd
:: StageRoute Env ~ Route App :: StageRoute Env ~ Route App
@ -478,6 +491,15 @@ parseAdd sender (AP.Add object target role _context) = do
GroupChildrenR g -> GroupChildrenR g ->
ATGroupChildren <$> ATGroupChildren <$>
WAP.decodeKeyHashidE g "Inavlid team children hashid" WAP.decodeKeyHashidE g "Inavlid team children hashid"
RepoProjectsR r ->
ATRepoProjects <$>
WAP.decodeKeyHashidE r "Inavlid repo projects hashid"
DeckProjectsR d ->
ATDeckProjects <$>
WAP.decodeKeyHashidE d "Inavlid deck projects hashid"
LoomProjectsR l ->
ATLoomProjects <$>
WAP.decodeKeyHashidE l "Inavlid loom projects hashid"
_ -> throwE "Not an Add target collection route" _ -> throwE "Not an Add target collection route"
) )
pure pure

View file

@ -930,6 +930,7 @@ instance YesodBreadcrumbs App where
RepoStampR r k -> ("Stamp #" <> keyHashidText k, Just $ RepoR r) RepoStampR r k -> ("Stamp #" <> keyHashidText k, Just $ RepoR r)
RepoCollabsR r -> ("Collaborators", Just $ RepoR r) RepoCollabsR r -> ("Collaborators", Just $ RepoR r)
RepoProjectsR r -> ("Projects", Just $ RepoR r)
DeckR d -> ("Ticket Tracker =" <> keyHashidText d, Just HomeR) DeckR d -> ("Ticket Tracker =" <> keyHashidText d, Just HomeR)
DeckInboxR d -> ("Inbox", Just $ DeckR d) DeckInboxR d -> ("Inbox", Just $ DeckR d)
@ -993,6 +994,7 @@ instance YesodBreadcrumbs App where
LoomStampR l k -> ("Stamp #" <> keyHashidText k, Just $ LoomR l) LoomStampR l k -> ("Stamp #" <> keyHashidText k, Just $ LoomR l)
LoomCollabsR l -> ("Collaborators", Just $ LoomR l) LoomCollabsR l -> ("Collaborators", Just $ LoomR l)
LoomProjectsR l -> ("Projects", Just $ LoomR l)
ClothR l c -> ("#" <> keyHashidText c, Just $ LoomClothsR l) ClothR l c -> ("#" <> keyHashidText c, Just $ LoomClothsR l)
ClothDiscussionR l c -> ("Discussion", Just $ ClothR l c) ClothDiscussionR l c -> ("Discussion", Just $ ClothR l c)

View file

@ -594,87 +594,10 @@ getDeckProjectsR deckHash = do
(deck, actor, stems, drafts) <- runDB $ do (deck, actor, stems, drafts) <- runDB $ do
deck <- get404 deckID deck <- get404 deckID
actor <- getJust $ deckActor deck actor <- getJust $ deckActor deck
stems <- stems <- getStems $ deckKomponent deck
E.select $ E.from $ \ (stem `E.InnerJoin` accept `E.InnerJoin` deleg `E.InnerJoin` grant) -> do drafts <- getStemDrafts $ deckKomponent deck
E.on $ deleg E.^. StemDelegateLocalGrant E.==. grant E.^. OutboxItemId return (deck, actor, stems, drafts)
E.on $ accept E.^. StemComponentAcceptId E.==. deleg E.^. StemDelegateLocalStem
E.on $ stem E.^. StemId E.==. accept E.^. StemComponentAcceptStem
E.where_ $ stem E.^. StemHolder E.==. E.val (deckKomponent deck)
return
( stem
, grant E.^. OutboxItemPublished
)
stems' <- for stems $ \ (Entity stemID stem, E.Value time) -> do
j <- getStemProject stemID
projectView <-
bitraverse
(\ projectID -> do
actorID <- projectActor <$> getJust projectID
actor <- getJust actorID
return (projectID, actor)
)
getRemoteActorData
j
return (projectView, stemRole stem, time, stemID)
drafts <-
E.select $ E.from $ \ (stem `E.LeftOuterJoin` accept `E.LeftOuterJoin` deleg) -> do
E.on $ accept E.?. StemComponentAcceptId E.==. deleg E.?. StemDelegateLocalStem
E.on $ E.just (stem E.^. StemId) E.==. accept E.?. StemComponentAcceptStem
E.where_ $
stem E.^. StemHolder E.==. E.val (deckKomponent deck) E.&&.
E.isNothing (deleg E.?. StemDelegateLocalId)
return stem
drafts' <- for drafts $ \ (Entity stemID (Stem role _)) -> do
(project, accept) <- do
project <- getStemProject stemID
accept <- isJust <$> getBy (UniqueStemComponentAccept stemID)
(,accept) <$> bitraverse
(\ j -> do
resourceID <- projectResource <$> getJust j
Resource actorID <- getJust resourceID
actor <- getJust actorID
return (j, actor)
)
getRemoteActorData
project
((inviter, time), us) <- do
usOrThem <-
requireEitherAlt
(getKeyBy $ UniqueStemOriginAdd stemID)
(getKeyBy $ UniqueStemOriginInvite stemID)
"Neither us nor them"
"Both us and them"
(addOrActor, us) <-
case usOrThem of
Left _usID -> (,True) <$>
requireEitherAlt
(fmap stemComponentGestureLocalActivity <$> getValBy (UniqueStemComponentGestureLocal stemID))
(fmap (stemComponentGestureRemoteActor &&& stemComponentGestureRemoteActivity) <$> getValBy (UniqueStemComponentGestureRemote stemID))
"Neither local not remote"
"Both local and remote"
Right themID -> (,False) <$>
requireEitherAlt
(fmap stemProjectGestureLocalInvite <$> getValBy (UniqueStemProjectGestureLocal themID))
(fmap (stemProjectGestureRemoteActor &&& stemProjectGestureRemoteInvite) <$> getValBy (UniqueStemProjectGestureRemote themID))
"Neither local not remote"
"Both local and remote"
(,us) <$> case addOrActor of
Left addID -> do
OutboxItem outboxID _ time <- getJust addID
Entity actorID actor <- getByJust $ UniqueActorOutbox outboxID
(,time) . Left . (,actor) <$> getLocalActor actorID
Right (actorID, addID) -> do
RemoteActivity _ _ time <- getJust addID
(,time) . Right <$> getRemoteActorData actorID
return (inviter, us, project, accept, time, role, stemID)
return (deck, actor, stems', drafts')
defaultLayout $(widgetFile "deck/projects") defaultLayout $(widgetFile "deck/projects")
where
getRemoteActorData actorID = do
actor <- getJust actorID
object <- getJust $ remoteActorIdent actor
inztance <- getJust $ remoteObjectInstance object
return (inztance, object, actor)
addProjectForm = renderDivs $ addProjectForm = renderDivs $
areq fedUriField "(URI) Project" Nothing areq fedUriField "(URI) Project" Nothing

View file

@ -33,6 +33,7 @@ module Vervis.Handler.Loom
, getLoomStampR , getLoomStampR
, getLoomCollabsR , getLoomCollabsR
, getLoomProjectsR
) )
where where
@ -54,6 +55,7 @@ import Yesod.Core
import Yesod.Core.Handler (redirect, setMessage, lookupPostParam, notFound) import Yesod.Core.Handler (redirect, setMessage, lookupPostParam, notFound)
import Yesod.Form.Functions (runFormPost, runFormGet) import Yesod.Form.Functions (runFormPost, runFormGet)
import Yesod.Form.Types (FormResult (..)) import Yesod.Form.Types (FormResult (..))
import Yesod.Form
import Yesod.Persist.Core (runDB, get404, getBy404) import Yesod.Persist.Core (runDB, get404, getBy404)
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
@ -88,11 +90,13 @@ import Vervis.Form.Tracker
import Vervis.Foundation import Vervis.Foundation
import Vervis.Model import Vervis.Model
import Vervis.Paginate import Vervis.Paginate
import Vervis.Persist.Actor
import Vervis.Persist.Collab import Vervis.Persist.Collab
import Vervis.Recipient import Vervis.Recipient
import Vervis.Settings import Vervis.Settings
import Vervis.Ticket import Vervis.Ticket
import Vervis.TicketFilter import Vervis.TicketFilter
import Vervis.Time
import Vervis.Web.Actor import Vervis.Web.Actor
import Vervis.Widget.Person import Vervis.Widget.Person
import Vervis.Widget.Ticket import Vervis.Widget.Ticket
@ -138,6 +142,8 @@ getLoomR loomHash = do
} }
, AP.patchTrackerCollaborators = , AP.patchTrackerCollaborators =
encodeRouteLocal $ LoomCollabsR loomHash encodeRouteLocal $ LoomCollabsR loomHash
, AP.patchTrackerProjects =
encodeRouteLocal $ LoomProjectsR loomHash
} }
provideHtmlAndAP loomAP $ redirect $ LoomClothsR loomHash provideHtmlAndAP loomAP $ redirect $ LoomClothsR loomHash
@ -358,3 +364,23 @@ getLoomStampR = servePerActorKey loomActor LocalActorLoom
getLoomCollabsR :: KeyHashid Loom -> Handler TypedContent getLoomCollabsR :: KeyHashid Loom -> Handler TypedContent
getLoomCollabsR loomHash = error "TODO getLoomCollabsR" getLoomCollabsR loomHash = error "TODO getLoomCollabsR"
getLoomProjectsR :: KeyHashid Loom -> Handler Html
getLoomProjectsR loomHash = do
loomID <- decodeKeyHashid404 loomHash
mp <- maybeAuthId
haveAdmin <- fmap isJust $ runDB $ runMaybeT $ do
personID <- MaybeT $ pure mp
loom <- lift $ get404 loomID
MaybeT $ getCapability personID (Left $ loomResource loom) AP.RoleAdmin
((_, widgetAP), enctypeAP) <- runFormPost addProjectForm
(loom, actor, stems, drafts) <- runDB $ do
loom <- get404 loomID
actor <- getJust $ loomActor loom
stems <- getStems $ loomKomponent loom
drafts <- getStemDrafts $ loomKomponent loom
return (loom, actor, stems, drafts)
defaultLayout $(widgetFile "loom/projects")
addProjectForm = renderDivs $
areq fedUriField "(URI) Project" Nothing

View file

@ -50,6 +50,7 @@ module Vervis.Handler.Repo
, getRepoStampR , getRepoStampR
, getRepoCollabsR , getRepoCollabsR
, getRepoProjectsR
@ -124,6 +125,7 @@ import Yesod.Core.Content
import Yesod.Core.Handler import Yesod.Core.Handler
import Yesod.Form.Functions (runFormPost) import Yesod.Form.Functions (runFormPost)
import Yesod.Form.Types (FormResult (..)) import Yesod.Form.Types (FormResult (..))
import Yesod.Form
import Yesod.Persist.Core import Yesod.Persist.Core
import qualified Data.ByteString as B import qualified Data.ByteString as B
@ -168,6 +170,7 @@ import Vervis.API
import Vervis.Federation.Auth import Vervis.Federation.Auth
import Vervis.Federation.Offer import Vervis.Federation.Offer
import Vervis.FedURI import Vervis.FedURI
import Vervis.Field.Person
import Vervis.Form.Repo import Vervis.Form.Repo
import Vervis.Foundation import Vervis.Foundation
import Vervis.Path import Vervis.Path
@ -175,15 +178,18 @@ import Vervis.Model
import Vervis.Model.Ident import Vervis.Model.Ident
import Vervis.Paginate import Vervis.Paginate
import Vervis.Persist.Actor import Vervis.Persist.Actor
import Vervis.Persist.Collab
import Vervis.Readme import Vervis.Readme
import Vervis.Recipient import Vervis.Recipient
import Vervis.Settings import Vervis.Settings
import Vervis.SourceTree import Vervis.SourceTree
import Vervis.Style import Vervis.Style
import Vervis.Time
import Vervis.Web.Actor import Vervis.Web.Actor
import Vervis.Web.Darcs import Vervis.Web.Darcs
import Vervis.Web.Delivery import Vervis.Web.Delivery
import Vervis.Web.Git import Vervis.Web.Git
import Vervis.Widget.Tracker
import qualified Vervis.Client as C import qualified Vervis.Client as C
import qualified Vervis.Formatting as F import qualified Vervis.Formatting as F
@ -233,6 +239,7 @@ getRepoR repoHash = do
encodeRouteLocal . LoomR . hashLoom <$> repoLoom repo encodeRouteLocal . LoomR . hashLoom <$> repoLoom repo
, AP.repoClone = encodeRouteLocal (RepoR repoHash) :| [] , AP.repoClone = encodeRouteLocal (RepoR repoHash) :| []
, AP.repoCollaborators = encodeRouteLocal $ RepoCollabsR repoHash , AP.repoCollaborators = encodeRouteLocal $ RepoCollabsR repoHash
, AP.repoProjects = encodeRouteLocal $ RepoProjectsR repoHash
} }
next = next =
@ -781,6 +788,27 @@ getRepoStampR = servePerActorKey repoActor LocalActorRepo
getRepoCollabsR :: KeyHashid Repo -> Handler TypedContent getRepoCollabsR :: KeyHashid Repo -> Handler TypedContent
getRepoCollabsR repoHash = error "TODO getRepoCollabsR" getRepoCollabsR repoHash = error "TODO getRepoCollabsR"
addProjectForm = renderDivs $
areq fedUriField "(URI) Project" Nothing
getRepoProjectsR :: KeyHashid Repo -> Handler Html
getRepoProjectsR repoHash = do
repoID <- decodeKeyHashid404 repoHash
mp <- maybeAuthId
haveAdmin <- fmap isJust $ runDB $ runMaybeT $ do
personID <- MaybeT $ pure mp
repo <- lift $ get404 repoID
MaybeT $ getCapability personID (Left $ repoResource repo) AP.RoleAdmin
((_, widgetAP), enctypeAP) <- runFormPost addProjectForm
(repo, actor, stems, drafts) <- runDB $ do
repo <- get404 repoID
actor <- getJust $ repoActor repo
stems <- getStems $ repoKomponent repo
drafts <- getStemDrafts $ repoKomponent repo
return (repo, actor, stems, drafts)
hashLoom <- getEncodeKeyHashid
defaultLayout $(widgetFile "repo/projects")

View file

@ -47,6 +47,7 @@ module Vervis.Persist.Actor
, insertToInbox , insertToInbox
, adaptErrbox , adaptErrbox
, getActivityIdent , getActivityIdent
, getRemoteActorData
) )
where where
@ -450,3 +451,9 @@ getActivityIdent =
act <- getJust actID act <- getJust actID
getRemoteActivityURI act getRemoteActivityURI act
) )
getRemoteActorData actorID = do
actor <- getJust actorID
object <- getJust $ remoteActorIdent actor
inztance <- getJust $ remoteObjectInstance object
return (inztance, object, actor)

View file

@ -57,10 +57,14 @@ module Vervis.Persist.Collab
, getPermitsForResource , getPermitsForResource
, getCapability , getCapability
, getStems
, getStemDrafts
) )
where where
import Control.Applicative import Control.Applicative
import Control.Arrow ((&&&))
import Control.Monad import Control.Monad
import Control.Monad.IO.Class import Control.Monad.IO.Class
import Control.Monad.Trans.Class import Control.Monad.Trans.Class
@ -1269,3 +1273,80 @@ getCapability personID actor role = do
u <- getRemoteActivityURI grant u <- getRemoteActivityURI grant
return $ Right u return $ Right u
return $ maybeDirect' <|> maybeExt' return $ maybeDirect' <|> maybeExt'
getStems komponentID = do
stems <-
E.select $ E.from $ \ (stem `E.InnerJoin` accept `E.InnerJoin` deleg `E.InnerJoin` grant) -> do
E.on $ deleg E.^. StemDelegateLocalGrant E.==. grant E.^. OutboxItemId
E.on $ accept E.^. StemComponentAcceptId E.==. deleg E.^. StemDelegateLocalStem
E.on $ stem E.^. StemId E.==. accept E.^. StemComponentAcceptStem
E.where_ $ stem E.^. StemHolder E.==. E.val komponentID
return
( stem
, grant E.^. OutboxItemPublished
)
for stems $ \ (Entity stemID stem, E.Value time) -> do
j <- getStemProject stemID
projectView <-
bitraverse
(\ projectID -> do
actorID <- projectActor <$> getJust projectID
actor <- getJust actorID
return (projectID, actor)
)
getRemoteActorData
j
return (projectView, stemRole stem, time, stemID)
getStemDrafts komponentID = do
drafts <-
E.select $ E.from $ \ (stem `E.LeftOuterJoin` accept `E.LeftOuterJoin` deleg) -> do
E.on $ accept E.?. StemComponentAcceptId E.==. deleg E.?. StemDelegateLocalStem
E.on $ E.just (stem E.^. StemId) E.==. accept E.?. StemComponentAcceptStem
E.where_ $
stem E.^. StemHolder E.==. E.val komponentID E.&&.
E.isNothing (deleg E.?. StemDelegateLocalId)
return stem
for drafts $ \ (Entity stemID (Stem role _)) -> do
(project, accept) <- do
project <- getStemProject stemID
accept <- isJust <$> getBy (UniqueStemComponentAccept stemID)
(,accept) <$> bitraverse
(\ j -> do
resourceID <- projectResource <$> getJust j
Resource actorID <- getJust resourceID
actor <- getJust actorID
return (j, actor)
)
getRemoteActorData
project
((inviter, time), us) <- do
usOrThem <-
requireEitherAlt
(getKeyBy $ UniqueStemOriginAdd stemID)
(getKeyBy $ UniqueStemOriginInvite stemID)
"Neither us nor them"
"Both us and them"
(addOrActor, us) <-
case usOrThem of
Left _usID -> (,True) <$>
requireEitherAlt
(fmap stemComponentGestureLocalActivity <$> getValBy (UniqueStemComponentGestureLocal stemID))
(fmap (stemComponentGestureRemoteActor &&& stemComponentGestureRemoteActivity) <$> getValBy (UniqueStemComponentGestureRemote stemID))
"Neither local not remote"
"Both local and remote"
Right themID -> (,False) <$>
requireEitherAlt
(fmap stemProjectGestureLocalInvite <$> getValBy (UniqueStemProjectGestureLocal themID))
(fmap (stemProjectGestureRemoteActor &&& stemProjectGestureRemoteInvite) <$> getValBy (UniqueStemProjectGestureRemote themID))
"Neither local not remote"
"Both local and remote"
(,us) <$> case addOrActor of
Left addID -> do
OutboxItem outboxID _ time <- getJust addID
Entity actorID actor <- getByJust $ UniqueActorOutbox outboxID
(,time) . Left . (,actor) <$> getLocalActor actorID
Right (actorID, addID) -> do
RemoteActivity _ _ time <- getJust addID
(,time) . Right <$> getRemoteActorData actorID
return (inviter, us, project, accept, time, role, stemID)

View file

@ -651,6 +651,7 @@ data Repo u = Repo
, repoLoom :: Maybe LocalURI , repoLoom :: Maybe LocalURI
, repoClone :: NonEmpty LocalURI , repoClone :: NonEmpty LocalURI
, repoCollaborators :: LocalURI , repoCollaborators :: LocalURI
, repoProjects :: LocalURI
} }
instance ActivityPub Repo where instance ActivityPub Repo where
@ -666,13 +667,15 @@ instance ActivityPub Repo where
<*> withAuthorityMaybeO h (o .:? "sendPatchesTo") <*> withAuthorityMaybeO h (o .:? "sendPatchesTo")
<*> (traverse (withAuthorityO h . pure) =<< o .:*+ "cloneUri") <*> (traverse (withAuthorityO h . pure) =<< o .:*+ "cloneUri")
<*> withAuthorityO h (o .: "collaborators") <*> withAuthorityO h (o .: "collaborators")
toSeries h (Repo actor team vcs loom clone collabs) <*> withAuthorityO h (o .: "context")
toSeries h (Repo actor team vcs loom clone collabs projects)
= toSeries h actor = toSeries h actor
<> "team" .= (ObjURI h <$> team) <> "team" .= (ObjURI h <$> team)
<> "versionControlSystem" .= vcs <> "versionControlSystem" .= vcs
<> "sendPatchesTo" .=? (ObjURI h <$> loom) <> "sendPatchesTo" .=? (ObjURI h <$> loom)
<> "cloneUri" .=*+ (ObjURI h <$> clone) <> "cloneUri" .=*+ (ObjURI h <$> clone)
<> "collaborators" .= ObjURI h collabs <> "collaborators" .= ObjURI h collabs
<> "context" .= ObjURI h projects
data TicketTracker u = TicketTracker data TicketTracker u = TicketTracker
{ ticketTrackerActor :: Actor u { ticketTrackerActor :: Actor u
@ -701,6 +704,7 @@ instance ActivityPub TicketTracker where
data PatchTracker u = PatchTracker data PatchTracker u = PatchTracker
{ patchTrackerActor :: Actor u { patchTrackerActor :: Actor u
, patchTrackerCollaborators :: LocalURI , patchTrackerCollaborators :: LocalURI
, patchTrackerProjects :: LocalURI
} }
instance ActivityPub PatchTracker where instance ActivityPub PatchTracker where
@ -712,9 +716,11 @@ instance ActivityPub PatchTracker where
fmap (h,) $ fmap (h,) $
PatchTracker a PatchTracker a
<$> withAuthorityO h (o .: "collaborators") <$> withAuthorityO h (o .: "collaborators")
toSeries h (PatchTracker actor collabs) <*> withAuthorityO h (o .: "context")
toSeries h (PatchTracker actor collabs projects)
= toSeries h actor = toSeries h actor
<> "collaborators" .= ObjURI h collabs <> "collaborators" .= ObjURI h collabs
<> "context" .= ObjURI h projects
data CollectionType = CollectionTypeUnordered | CollectionTypeOrdered data CollectionType = CollectionTypeUnordered | CollectionTypeOrdered

View file

@ -0,0 +1,70 @@
$# This file is part of Vervis.
$#
$# Written in 2016, 2019, 2022, 2023, 2024
$# by fr33domlover <fr33domlover@riseup.net>.
$#
$# ♡ Copying is an act of love. Please copy, reuse and share.
$#
$# The author(s) have dedicated all copyright and related and neighboring
$# rights to this software to the public domain worldwide. This software is
$# distributed without any warranty.
$#
$# You should have received a copy of the CC0 Public Domain Dedication along
$# with this software. If not, see
$# <http://creativecommons.org/publicdomain/zero/1.0/>.
^{loomNavW (Entity loomID loom) actor}
<h2>Projects
<table>
<tr>
<th>Role
<th>Project
<th>Since
$if haveAdmin
<th>Remove
$forall (project, role, since, stemID) <- stems
<tr>
<td>#{show role}
<td>^{projectLinkFedW project}
<td>#{showDate since}
$# $if haveAdmin
$# <td>^{buttonW POST "Remove" (LoomRemoveProjectR loomHash stemID)}
$#$if haveAdmin
$# <p>Add loom to a project:
$# <form method=POST action=@{LoomAddProjectR loomHash} enctype=#{enctypeAP}>
$# ^{widgetAP}
$# <input type="submit">
<h2>Invites
<table>
<tr>
<th>Inviter
<th>Via
<th>Invited project
<th>I accepted?
<th>Role
<th>Time
$if haveAdmin
<th>Approve
$forall (inviter, us, project, accept, time, role, stemID) <- drafts
<tr>
<td>^{actorLinkFedW inviter}
<td>
$if us
Us
$else
Them
<td>^{projectLinkFedW project}
<td>
$if accept
[x]
$else
[_]
<td>#{show role}
<td>#{showDate time}
$# $if haveAdmin && (not accept && not us)
$# <td>^{buttonW POST "Approve" (LoomApproveProjectR loomHash stemID)}

View file

@ -33,6 +33,9 @@ $# <http://creativecommons.org/publicdomain/zero/1.0/>.
<span> <span>
<a href=@{LoomCollabsR loomHash}> <a href=@{LoomCollabsR loomHash}>
[🤝 Collaborators] [🤝 Collaborators]
<span>
<a href=@{LoomProjectsR loomHash}>
[🏗 Projects]
<span> <span>
<a href=@{LoomClothsR loomHash}> <a href=@{LoomClothsR loomHash}>
[🥂 Merge Requests] [🥂 Merge Requests]

View file

@ -0,0 +1,100 @@
$# This file is part of Vervis.
$#
$# Written in 2016, 2019, 2022, 2023, 2024
$# by fr33domlover <fr33domlover@riseup.net>.
$#
$# ♡ Copying is an act of love. Please copy, reuse and share.
$#
$# The author(s) have dedicated all copyright and related and neighboring
$# rights to this software to the public domain worldwide. This software is
$# distributed without any warranty.
$#
$# You should have received a copy of the CC0 Public Domain Dedication along
$# with this software. If not, see
$# <http://creativecommons.org/publicdomain/zero/1.0/>.
<div>
<span>
[[ 🗃
<a href=@{RepoR repoHash}>
^#{keyHashidText repoHash} #{actorName actor}
]] ::
<span>
<a href=@{RepoInboxR repoHash}>
[📥 Inbox]
<span>
<a href=@{RepoOutboxR repoHash}>
[📤 Outbox]
<span>
<a href=@{RepoErrboxR repoHash}>
[💥 Errbox]
<span>
<a href=@{RepoFollowersR repoHash}>
[🐤 Followers]
<span>
<a href=@{RepoCollabsR repoHash}>
[🤝 Collaborators]
<span>
<a href=@{RepoProjectsR repoHash}>
[🏗 Projects]
<span>
<a href=@{RepoCommitsR repoHash}>
[🛠 Commits]
$maybe loomID <- repoLoom repo
<span>
<a href=@{LoomClothsR $ hashLoom loomID}>
[🧩 Merge Requests]
<h2>Projects
<table>
<tr>
<th>Role
<th>Project
<th>Since
$if haveAdmin
<th>Remove
$forall (project, role, since, stemID) <- stems
<tr>
<td>#{show role}
<td>^{projectLinkFedW project}
<td>#{showDate since}
$# $if haveAdmin
$# <td>^{buttonW POST "Remove" (RepoRemoveProjectR repoHash stemID)}
$#$if haveAdmin
$# <p>Add repo to a project:
$# <form method=POST action=@{RepoAddProjectR repoHash} enctype=#{enctypeAP}>
$# ^{widgetAP}
$# <input type="submit">
<h2>Invites
<table>
<tr>
<th>Inviter
<th>Via
<th>Invited project
<th>I accepted?
<th>Role
<th>Time
$if haveAdmin
<th>Approve
$forall (inviter, us, project, accept, time, role, stemID) <- drafts
<tr>
<td>^{actorLinkFedW inviter}
<td>
$if us
Us
$else
Them
<td>^{projectLinkFedW project}
<td>
$if accept
[x]
$else
[_]
<td>#{show role}
<td>#{showDate time}
$# $if haveAdmin && (not accept && not us)
$# <td>^{buttonW POST "Approve" (RepoApproveProjectR repoHash stemID)}

View file

@ -51,6 +51,9 @@ $# ^{personNavW user}
<span> <span>
<a href=@{RepoCollabsR repo}> <a href=@{RepoCollabsR repo}>
[🤝 Collaborators] [🤝 Collaborators]
<span>
<a href=@{RepoProjectsR repo}>
[🏗 Projects]
<span> <span>
<a href=@{RepoCommitsR repo}> <a href=@{RepoCommitsR repo}>
[🛠 Changes] [🛠 Changes]

View file

@ -51,6 +51,9 @@ $# ^{personNavW user}
<span> <span>
<a href=@{RepoCollabsR repo}> <a href=@{RepoCollabsR repo}>
[🤝 Collaborators] [🤝 Collaborators]
<span>
<a href=@{RepoProjectsR repo}>
[🏗 Projects]
<span> <span>
<a href=@{RepoCommitsR repo}> <a href=@{RepoCommitsR repo}>
[🛠 Commits] [🛠 Commits]

View file

@ -221,6 +221,7 @@
/repos/#RepoKeyHashid/stamps/#SigKeyKeyHashid RepoStampR GET /repos/#RepoKeyHashid/stamps/#SigKeyKeyHashid RepoStampR GET
/repos/#RepoKeyHashid/collabs RepoCollabsR GET /repos/#RepoKeyHashid/collabs RepoCollabsR GET
/repos/#RepoKeyHashid/projects RepoProjectsR GET
---- Deck -------------------------------------------------------------------- ---- Deck --------------------------------------------------------------------
@ -305,6 +306,7 @@
/looms/#LoomKeyHashid/stamps/#SigKeyKeyHashid LoomStampR GET /looms/#LoomKeyHashid/stamps/#SigKeyKeyHashid LoomStampR GET
/looms/#LoomKeyHashid/collabs LoomCollabsR GET /looms/#LoomKeyHashid/collabs LoomCollabsR GET
/looms/#LoomKeyHashid/projects LoomProjectsR GET
---- Cloth ------------------------------------------------------------------- ---- Cloth -------------------------------------------------------------------