summaryrefslogtreecommitdiff
path: root/desk/app/chat.hoon
diff options
context:
space:
mode:
Diffstat (limited to 'desk/app/chat.hoon')
-rw-r--r--desk/app/chat.hoon497
1 files changed, 497 insertions, 0 deletions
diff --git a/desk/app/chat.hoon b/desk/app/chat.hoon
new file mode 100644
index 0000000..1dcfadd
--- /dev/null
+++ b/desk/app/chat.hoon
@@ -0,0 +1,497 @@
+:: chat-stream: chat proxy for earthlings
+::
+:: makes specified chats accessible over unauthenticated http requests.
+:: GET at /stream/chat-name.json to receive json updates as messages happen.
+:: POST at /stream/chat-name with a body to send a chat message.
+::
+:: hands out temporary identities (using fakeid) using which stream viewers
+:: can post to exposed chats.
+:: NOTE that the cookie it gives out is marked Secure and SameSite=None!
+::
+:: when streaming a chat, any messages sent into it (by real identities)
+:: of the form "!ban ~ship" will result in an ip ban for that ship,
+:: denying them posting privileges in all local streams.
+::
+:: usage: poke with an action. ie :chat-stream [%stream %urbit-help]
+::
+/- chat
+/+ chat-json,
+ default-agent, verb, dbug,
+ fid=fakeid, *server
+::
+|%
++$ state-0
+ $: %0
+ streams=(set source)
+ viewers=(jug source eyre-id)
+ ::TODO we need to expire these to avoid a space-leak
+ :: probably clean up expired ids every +identity-duration
+ ::TODO shouldn't this live in fakeid-store instead? but how update?
+ guests=(map ship (set address:eyre))
+ banned=(set address:eyre)
+ ==
+::
+::NOTE we could support _streaming_ foreign chats fairly easily,
+:: but posting to them is a way different story,
+:: so we just go full local-only for now.
++$ source term
+::
++$ eyre-id @ta
+::
++$ action
+ $% [%stream =source]
+ [%stop =source]
+ [%ban name=@p]
+ [%unban =address:eyre]
+ ==
+::
++$ card card:agent:gall
+--
+::
+=| state-0
+=* state -
+::
+%- agent:dbug
+%+ verb |
+::
+^- agent:gall
+=<
+ |_ =bowl:gall
+ +* this .
+ do ~(. +> bowl)
+ def ~(. (default-agent this %|) bowl)
+ ::
+ ++ on-init
+ ^- (quip card _this)
+ ::NOTE careful! install currently proceeds fine if this crashes.
+ :: you'll need to |uninstall the desk and |nuke the app.
+ |^ =+ (check-dependency %fakeid-store)
+ =+ (check-dependency %chat)
+ :_ this
+ :~ [%pass /connect %arvo %e %connect [~ /stream] dap.bowl]
+ kick-heartbeat:do
+ ==
+ ::
+ ++ check-dependency
+ |= app=dude:gall
+ ~| [%missing-dependency %app app]
+ ?> .^(? %gu /(scot %p our.bowl)/[app]/(scot %da now.bowl)/$)
+ ~
+ --
+ ::
+ ++ on-save !>(state)
+ ::
+ ++ on-load
+ |= old=vase
+ ^- (quip card _this)
+ [~ this(state !<(state-0 old))]
+ ::
+ ++ on-poke
+ |= [=mark =vase]
+ ^- (quip card _this)
+ ?+ mark (on-poke:def mark vase)
+ %noun $(mark %stream-action)
+ ::
+ %stream-action
+ =/ =action !<(action vase)
+ =^ cards state
+ ?- -.action
+ %stream (start-stream:do +.action)
+ %stop (stop-stream:do +.action)
+ %ban (ban-comet:do +.action)
+ %unban (unban-ip:do +.action)
+ ==
+ [cards this]
+ ::
+ %handle-http-request
+ =^ cards state
+ %- handle-http-request:do
+ !<([=eyre-id =inbound-request:eyre] vase)
+ [cards this]
+ ==
+ ::
+ ++ on-watch
+ |= =path
+ ?: ?=([%http-response @ ~] path)
+ [~ this]
+ (on-watch:def path)
+ ::
+ ++ on-leave
+ |= =path
+ ^- (quip card _this)
+ ?. ?=([%http-response @ ~] path)
+ (on-leave:def path)
+ =/ who=eyre-id i.t.path
+ :- ~
+ =- this(viewers -)
+ ::NOTE we really only delete from one, but we don't want to keep a
+ :: reverse lookup just for that optimization.
+ %- ~(run by viewers)
+ |= v=(set eyre-id)
+ (~(del in v) who)
+ ::
+ ++ on-agent
+ |= [=wire =sign:agent:gall]
+ ^- (quip card _this)
+ ?: ?=(%poke-ack -.sign)
+ ?~ p.sign [~ this]
+ %- (slog leaf+"failed poke on {(spud wire)}" u.p.sign)
+ [~ this]
+ ?. ?=([%listen @ ~] wire) (on-agent:def wire sign)
+ =* source i.t.wire
+ ?+ -.sign (on-agent:def wire sign)
+ %kick
+ [[(watch-chat:do our.bowl source)]~ this]
+ ::
+ %fact
+ =* mark p.cage.sign
+ =* vase q.cage.sign
+ ?+ mark (on-agent:def wire sign)
+ %writ-diff
+ =^ cards state
+ (handle-chat-update:do source !<(diff:writs:chat vase))
+ [cards this]
+ ==
+ ==
+ ::
+ ++ on-arvo
+ |= [=wire =sign-arvo]
+ ^- (quip card _this)
+ ?+ sign-arvo (on-arvo:def wire sign-arvo)
+ [%eyre %bound *]
+ ~? !accepted.sign-arvo
+ [dap.bowl 'bind rejected!' binding.sign-arvo]
+ [~ this]
+ ::
+ [%behn %wake *]
+ ?. ?=([%heartbeat ~] wire) (on-arvo:def wire sign-arvo)
+ [send-heartbeat:do this]
+ ==
+ ::
+ ++ on-peek on-peek:def
+ ++ on-fail on-fail:def
+ --
+::
+|_ =bowl:gall
++* fakeid ~(. fid bowl)
+::
+:: config
+::
+++ identity-duration ~d7
+++ initial-messages 25
+++ max-message-length 280
+++ heartbeat-timer ~s30
+::
+:: card builders
+::
+++ kick-heartbeat
+ ^- card
+ [%pass /heartbeat %arvo %b %wait (add now.bowl heartbeat-timer)]
+::
+++ send-heartbeat
+ ^- (list card)
+ :- kick-heartbeat
+ =/ viewers=(list eyre-id)
+ %~ tap in
+ %+ roll ~(val by viewers)
+ |= [s=(set eyre-id) o=(set eyre-id)]
+ (~(uni in o) s)
+ ?: =(0 (lent viewers)) ~
+ :_ ~
+ :* %give
+ %fact
+ ::
+ %+ turn viewers
+ |= =eyre-id
+ /http-response/[eyre-id]
+ ::
+ %http-response-data
+ !> ^- (unit octs)
+ `[1 '\0a'] ::TODO prefix with : ?
+ ==
+::
+++ watch-chat
+ |= [our=ship =term]
+ ^- card
+ :* %pass
+ /listen/[term]
+ %agent
+ [our %chat]
+ %watch
+ /chat/(scot %p our)/[term]/ui/writs
+ ==
+::
+++ leave-chat
+ |= [our=ship =term]
+ ^- card
+ [%pass /listen/[term] %agent [our %chat] %leave ~]
+::
+++ send-to-viewers
+ |= [=source =json]
+ ^- (list card)
+ =/ ids=(set eyre-id)
+ (~(get ju viewers) source)
+ ?: =(~ ids) ~
+ :_ ~
+ :* %give
+ %fact
+ ::
+ %+ turn ~(tap in ids)
+ |= =eyre-id
+ /http-response/[eyre-id]
+ ::
+ %http-response-data
+ !> ^- (unit octs)
+ %- some
+ (make-stream-data json)
+ ==
+::
+++ make-stream-data
+ |= =json
+ ^- octs
+ %- as-octs:mimes:html
+ (rap 3 'data:' (en:json:html json) '\0a\0a' ~)
+::
+:: actions
+::
+++ start-stream
+ |= =source
+ ^- (quip card _state)
+ ?: ?| ?=(~ source)
+ (~(has in streams) source)
+ ==
+ [~ state]
+ :- [(watch-chat our.bowl source)]~
+ state(streams (~(put in streams) source))
+::
+++ stop-stream
+ |= =source
+ ^- (quip card _state)
+ ?. (~(has in streams) source)
+ [~ state]
+ :- [(leave-chat our.bowl source)]~
+ %_ state
+ streams (~(del in streams) source)
+ viewers (~(del by viewers) source)
+ ==
+::
+++ ban-comet
+ |= who=ship
+ ^- (quip card _state)
+ :- ~
+ %_ state
+ guests (~(del by guests) who)
+ banned (~(uni in banned) (~(get ju guests) who))
+ ==
+::
+++ unban-ip
+ |= =address:eyre
+ ^- (quip card _state)
+ :- ~
+ %_ state
+ banned (~(del in banned) address.action)
+ ==
+::
+:: outgoing flows
+::
+++ handle-chat-update
+ |= [=source =diff:writs:chat]
+ ^- (quip card _state)
+ ?. ?=(%add -.q.diff)
+ [~ state]
+ ?. (~(has in streams) source)
+ ~& [dap.bowl %unexpected-diff-for source]
+ [~ state]
+ :: accept !ban commands from real identites,
+ :: as plaintext "!ban " followed by a mention
+ ::
+ =/ banning=(unit @p)
+ ?. (lte (met 3 author.p.q.diff) 8) ~
+ =/ body=(list inline:chat)
+ ?+ -.content.p.q.diff ~
+ %story q.p.content.p.q.diff
+ ==
+ ?. ?=([%'!ban ' [%ship @] *] body) ~
+ `p.i.t.body
+ =^ caz state
+ ?~ banning [~ state]
+ (ban-comet u.banning)
+ :_ state
+ :: forward posts to all viewers
+ ::
+ %+ send-to-viewers source
+ (memo:enjs:chat-json p.q.diff)
+::
+:: incoming flows
+::
+++ handle-http-request
+ |= [=eyre-id =inbound-request:eyre]
+ ^- (quip card _state)
+ ?+ method.request.inbound-request
+ [(give-simple-payload:app eyre-id not-found:gen) state]
+ ::
+ %'GET' (handle-get eyre-id inbound-request)
+ %'POST' (handle-post eyre-id inbound-request)
+ ==
+::
+::TODO find a better way to structure this logic
+++ handle-get
+ |= [=eyre-id =inbound-request:eyre]
+ ^- (quip card _state)
+ =- =^ [card=(unit card) simple-payload:http] state
+ -
+ =. headers.response-header
+ :* 'Content-Type'^'text/event-stream'
+ 'Cache-Control'^'no-cache'
+ 'Connection'^'keep-alive'
+ headers.response-header
+ ==
+ :_ state
+ =/ header=cage [%http-response-header !>(response-header)]
+ =/ data=cage [%http-response-data !>(data)]
+ =/ =path /http-response/[eyre-id]
+ :* [%give %fact ~[path] header]
+ [%give %fact ~[path] data]
+ ::
+ %+ weld (drop card)
+ ^- (list ^card)
+ ?: =(200 status-code.response-header) ~
+ [%give %kick ~[path] ~]~
+ ==
+ ^- [[(unit card) simple-payload:http] _state]
+ =/ [[ext=(unit @ta) site=(list @t)] *]
+ %- parse-request-line
+ url.request.inbound-request
+ :: ignore requests that point to unsupported resources
+ ::
+ ?. &(?=([%stream @ ~] site) ?=([~ %json] ext))
+ [[~ not-found:gen] state]
+ =/ =source i.t.site
+ ?. (~(has in streams) source)
+ [[~ not-found:gen] state]
+ :: add eyre-id as viewer for requested source
+ ::
+ =. viewers
+ (~(put ju viewers) source eyre-id)
+ :: find or create session for request
+ ::
+ =/ who=(unit session:fakeid)
+ (session-from-request:fakeid inbound-request)
+ =/ [out=(unit card) =session-key:fakeid =session:fakeid]
+ ?^ who [~ *session-key:fakeid u.who]
+ =< [`card session-key session]
+ (new-session:fakeid identity-duration)
+ =/ =header-list:http
+ ?^ who ~
+ ::TODO don't need samesite=none in some contexts, but how can we tell?
+ (set-session-cookie:fakeid session-key until.session &)
+ :: keep track of all addresses this session has connected from,
+ :: but never track localhost requests
+ ::
+ =? guests !=(.127.0.0.1 address.inbound-request)
+ %+ ~(put ju guests)
+ name.session
+ address.inbound-request
+ :_ state
+ :- out
+ :: build response from some recent messages
+ ::
+ ^- simple-payload:http
+ :- [200 header-list]
+ %- some
+ %- make-stream-data
+ :- %a
+ =- (turn - |=([* * m=memo:chat] (memo:enjs:chat-json m)))
+ ^- (list [time writ:chat])
+ %- tap:((on time writ:chat) lte)
+ .^ ((mop time writ:chat) lte)
+ %gx
+ (scot %p our.bowl)
+ %chat
+ (scot %da now.bowl)
+ %chat
+ (scot %p our.bowl)
+ source
+ /writs/newest/(scot %ud initial-messages)/chat-writs
+ ==
+::
+++ handle-post
+ |= [=eyre-id =inbound-request]
+ ^- (quip card _state)
+ :_ state
+ =; [out=(unit card) =simple-payload:http]
+ %+ weld (drop out)
+ (give-simple-payload:app eyre-id simple-payload)
+ :: request must have sane target
+ ::
+ =/ [[ext=(unit @ta) site=(list @t)] *]
+ %- parse-request-line
+ url.request.inbound-request
+ ?. &(?=([%stream @ ~] site) ?=(~ ext))
+ `not-found:gen
+ =/ =source i.t.site
+ :: request must have some content
+ ::
+ =/ body=@t
+ q:(fall body.request.inbound-request *octs)
+ ?: =(~ body)
+ `[[400 ~] ~]
+ :: reject requests from banned addresses
+ ::
+ ?: (~(has in banned) address.inbound-request)
+ `[[403 ~] `(as-octs:mimes:html 'ur banned, fool!')]
+ :: reject requests without fakeid sessions
+ ::
+ =/ who=(unit ship)
+ (identity-from-request:fakeid inbound-request)
+ ?~ who
+ `[[403 ~] `(as-octs:mimes:html 'no session cookie')]
+ ::
+ :_ [[200 ~] ~]
+ %- some
+ %^ send-message
+ source
+ u.who
+ :+ %story ~
+ :_ ~
+ %- text-to-content
+ (end 3^max-message-length body)
+::
+++ text-to-content
+ %+ curr rash
+ ::NOTE we intentionally don't do #expression parsing
+ |^ ;~ pose
+ (cook |=(=@t [%link t t]) turl)
+ text
+ ==
+ :: +turl: url parser
+ ::
+ ++ turl
+ =- (sear - text)
+ |= t=cord
+ ^- (unit cord)
+ ?~((rush t aurf:de-purl:html) ~ `t)
+ :: +text: text message body
+ ::
+ ++ text
+ (cook crip (plus next))
+ --
+::
+++ send-message
+ |= [=source as=ship =content:chat]
+ ^- card
+ :* %pass
+ /send/[source]
+ %agent
+ [our.bowl %chat]
+ %poke
+ %chat-action-0
+ ::
+ !> ^- action:chat
+ :- [our.bowl source]
+ :+ now.bowl %writs
+ ^- diff:writs:chat
+ ::TODO as in place of our for msg id?
+ [[our.bowl now.bowl] %add [~ as now.bowl content]]
+ ==
+--