| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096 |
- package main
- /*
- SharedSpace - Collaboration backbone endpoints
- author: tobychui / AI assisted
- HTTP + WebSocket wiring for mod/sharedspace, the ArozOS collaboration
- backbone: multi-user spaces holding chat texts, images, files and
- revision-synced collaborative documents, with open / public / private
- access control, optional persistence and a realtime channel per space.
- MeetRoom runs its meeting signaling over the same channels.
- User endpoints (login required; the space ACL is the authorization
- layer - see mod/sharedspace/access.go):
- POST /system/sharedspace/create name=&access=&persistent=
- POST /system/sharedspace/delete spaceid= (manage)
- GET /system/sharedspace/info?spaceid=
- GET /system/sharedspace/list (joined)
- GET /system/sharedspace/listpublic
- POST /system/sharedspace/join spaceid=
- POST /system/sharedspace/leave spaceid=
- POST /system/sharedspace/members/add spaceid=&username=&role=
- POST /system/sharedspace/members/remove spaceid=&username=
- POST /system/sharedspace/members/role spaceid=&username=&role=
- POST /system/sharedspace/access spaceid=&access=
- POST /system/sharedspace/meta spaceid=&key=&value=
- GET /system/sharedspace/items?spaceid=
- POST /system/sharedspace/addtext spaceid=&text=
- POST /system/sharedspace/upload multipart (spaceid, file)
- GET /system/sharedspace/download?spaceid=&itemid=[&inline=1]
- POST /system/sharedspace/removeitem spaceid=&itemid=
- POST /system/sharedspace/doc/create spaceid=&name=
- GET /system/sharedspace/doc/list?spaceid=
- GET /system/sharedspace/doc/get?spaceid=&docid=
- POST /system/sharedspace/doc/update spaceid=&docid=&baserev=&content=
- POST /system/sharedspace/doc/delete spaceid=&docid=
- GET /system/sharedspace/ws?spaceid= WebSocket upgrade
- Admin endpoints (System Settings > Shared Spaces):
- GET /system/sharedspace/admin/list
- POST /system/sharedspace/admin/delete spaceid=
- GET /system/sharedspace/admin/config
- POST /system/sharedspace/admin/setconfig maxupload=&maxitems=&allowpersistent=&retentiondays=
- WebSocket protocol (JSON frames over /system/sharedspace/ws):
- client -> server: {"type":"signal","to":subid,"data":{...}} ephemeral relay (WebRTC etc.)
- {"type":"broadcast","data":{...}} ephemeral fan-out to peers
- {"type":"chat","text":"..."} persisted text item
- {"type":"doc-update","docid":"..","baserev":n,"content":".."}
- {"type":"ping"} app-level heartbeat
- server -> client: {"type":"welcome","subid":n,"username":u,"space":{...},"peers":[...]}
- {"type":"peer-join","peer":{...}} / {"type":"peer-leave","subid":n,"username":u}
- {"type":"signal","from":n,"data":{...}}
- {"type":"broadcast","from":n,"username":u,"data":{...}}
- {"type":"item","item":{...}} / {"type":"item-removed","itemid":i}
- {"type":"doc","docid":d,"revision":r,"by":u,"patch":{pos,del,ins}}
- {"type":"doc-created","doc":{...}} / {"type":"doc-deleted","docid":d}
- {"type":"doc-conflict","docid":d,"revision":r} sender only
- {"type":"member","action":a,"username":u,"role":r}
- {"type":"error","error":"..."} sender only
- {"type":"pong"}, {"type":"space-closed"}
- Ephemeral frames (signal / broadcast) fan out through the space channel
- and are never persisted; chat and documents write through the space so
- AGI scripts and, on meeting spaces, MeetRoom clients see them too. A
- client that receives a doc revision gap re-fetches with doc/get - the
- server content is always authoritative. Document saves should be
- debounced client-side (~500ms); every accepted revision is one database
- write.
- */
- import (
- "encoding/json"
- "mime"
- "net/http"
- "net/url"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "time"
- "github.com/gorilla/websocket"
- prout "imuslab.com/arozos/mod/prouter"
- "imuslab.com/arozos/mod/sharedspace"
- "imuslab.com/arozos/mod/utils"
- )
- const (
- ssMaxChatLength = 4000 // runes per chat message over the ws
- ssMaxSocketFrame = 512 << 10 // 512KB per ws frame (doc contents included)
- ssPingInterval = 20 * time.Second // server keepalive ping cadence
- ssReadTimeout = 60 * time.Second // drop a subscriber whose socket goes silent this long
- ssWriteTimeout = 10 * time.Second // per-frame write deadline
- ssSweepInterval = 1 * time.Hour // retention sweep cadence
- //Admin configuration keys, stored in the sharedspace table next to the
- //space records (see mod/sharedspace/persistence.go for the layout)
- ssDBTable = "sharedspace"
- ssConfMaxUpload = "conf/maxupload"
- ssConfMaxItems = "conf/maxitems"
- ssConfAllowPersistent = "conf/allowpersistent"
- ssConfRetentionDays = "conf/retentiondays"
- )
- var (
- sharedSpaceManager *sharedspace.Manager
- ssUpgrader = websocket.Upgrader{
- ReadBufferSize: 4096,
- WriteBufferSize: 4096,
- CheckOrigin: func(r *http.Request) bool { return true },
- }
- )
- /* ================= Configuration helpers ================= */
- // ssConfInt64 reads an integer admin setting, falling back when unset or
- // out of range.
- func ssConfInt64(key string, fallback int64) int64 {
- if sysdb == nil || !sysdb.KeyExists(ssDBTable, key) {
- return fallback
- }
- value := int64(0)
- sysdb.Read(ssDBTable, key, &value)
- if value <= 0 {
- return fallback
- }
- return value
- }
- // ssConfBool reads a boolean admin setting, falling back when unset.
- func ssConfBool(key string, fallback bool) bool {
- if sysdb == nil || !sysdb.KeyExists(ssDBTable, key) {
- return fallback
- }
- value := fallback
- sysdb.Read(ssDBTable, key, &value)
- return value
- }
- /* ================= Descriptors ================= */
- // ssDescribeSpace renders the space fields shared with clients. When a
- // username is given the caller's own role is included.
- func ssDescribeSpace(space *sharedspace.Space, username string) map[string]interface{} {
- desc := map[string]interface{}{
- "spaceid": space.ID,
- "name": space.Name,
- "owner": space.Owner,
- "access": space.AccessMode(),
- "persistent": space.Persistent,
- "items": space.ItemCount(),
- "docs": space.DocCount(),
- "members": space.MemberCount(),
- "metadata": space.Metadata(),
- "createdat": space.CreatedAt.Unix(),
- }
- if username != "" {
- role, isMember := space.Role(username)
- desc["myrole"] = role
- desc["ismember"] = isMember
- }
- return desc
- }
- // ssDescribeItem renders a space item for clients.
- func ssDescribeItem(item *sharedspace.Item) map[string]interface{} {
- return map[string]interface{}{
- "itemid": item.ID,
- "type": item.Type,
- "name": item.Name,
- "text": item.Text,
- "size": item.Size,
- "uploader": item.Uploader,
- "origin": item.Origin,
- "time": item.CreatedAt.Unix(),
- }
- }
- // ssDescribeDoc renders a document snapshot for clients. includeContent
- // controls whether the body travels with it.
- func ssDescribeDoc(doc *sharedspace.DocSnapshot, includeContent bool) map[string]interface{} {
- desc := map[string]interface{}{
- "docid": doc.ID,
- "name": doc.Name,
- "creator": doc.Creator,
- "revision": doc.Revision,
- "createdat": doc.CreatedAt.Unix(),
- "updatedat": doc.UpdatedAt.Unix(),
- "updatedby": doc.UpdatedBy,
- }
- if includeContent {
- desc["content"] = doc.Content
- }
- return desc
- }
- /* ================= Request helpers ================= */
- // ssRequestParam reads a request parameter from POST form or query string
- // depending on the endpoint's method convention.
- func ssRequestParam(r *http.Request, post bool, key string) (string, error) {
- if post {
- return utils.PostPara(r, key)
- }
- return utils.GetPara(r, key)
- }
- // ssResolveSpaceRequest authenticates the caller and resolves the spaceid
- // parameter to a live space the caller may read. On failure the error
- // response is already written and ok is false.
- func ssResolveSpaceRequest(w http.ResponseWriter, r *http.Request, post bool) (username string, space *sharedspace.Space, ok bool) {
- userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
- if err != nil {
- utils.SendErrorResponse(w, "Not logged in")
- return "", nil, false
- }
- spaceID, err := ssRequestParam(r, post, "spaceid")
- if err != nil {
- utils.SendErrorResponse(w, "Missing space ID")
- return "", nil, false
- }
- space, exists := sharedSpaceManager.GetSpace(spaceID)
- if !exists || !space.CanRead(userinfo.Username) {
- //Private spaces are indistinguishable from missing ones to outsiders
- utils.SendErrorResponse(w, "Space not found")
- return "", nil, false
- }
- return userinfo.Username, space, true
- }
- // ssCloseSpace announces the shutdown to live subscribers and deletes the
- // space (used by the user delete endpoint and the admin surface).
- func ssCloseSpace(spaceID string) {
- space, ok := sharedSpaceManager.GetSpace(spaceID)
- if !ok {
- return
- }
- space.Channel().Broadcast([]byte(`{"type":"space-closed"}`), -1)
- sharedSpaceManager.DeleteSpace(spaceID)
- }
- // ssBroadcastSpaceEvent builds the event listener that fans space mutations
- // out to the space's channel as protocol frames. Registered once per space
- // under a fixed key, so repeated registration is idempotent.
- func ssBroadcastSpaceEvent(space *sharedspace.Space) func(*sharedspace.SpaceEvent) {
- channel := space.Channel()
- return func(event *sharedspace.SpaceEvent) {
- var frame []byte
- switch event.Kind {
- case sharedspace.EventItemAdded:
- frame = mrMarshalOrDrop(map[string]interface{}{
- "type": "item",
- "item": ssDescribeItem(event.Item),
- })
- case sharedspace.EventItemRemoved:
- frame = mrMarshalOrDrop(map[string]interface{}{
- "type": "item-removed",
- "itemid": event.Item.ID,
- })
- case sharedspace.EventDocCreated:
- frame = mrMarshalOrDrop(map[string]interface{}{
- "type": "doc-created",
- "doc": ssDescribeDoc(event.Doc, false),
- })
- case sharedspace.EventDocUpdated:
- frame = mrMarshalOrDrop(map[string]interface{}{
- "type": "doc",
- "docid": event.Doc.ID,
- "revision": event.Doc.Revision,
- "by": event.Doc.UpdatedBy,
- "patch": map[string]interface{}{
- "pos": event.Patch.Pos,
- "del": event.Patch.Del,
- "ins": event.Patch.Ins,
- },
- })
- case sharedspace.EventDocDeleted:
- frame = mrMarshalOrDrop(map[string]interface{}{
- "type": "doc-deleted",
- "docid": event.Doc.ID,
- })
- case sharedspace.EventMemberChanged:
- frame = mrMarshalOrDrop(map[string]interface{}{
- "type": "member",
- "action": event.Action,
- "username": event.Member,
- "role": event.Role,
- })
- }
- if frame != nil {
- channel.Broadcast(frame, -1)
- }
- }
- }
- // ssResolvePersistRoot returns the durable blob root for shared spaces.
- // Space files live with the user data under the -root directory (default
- // ./files) rather than the system folder; a storage tree from the previous
- // layout under system/ is migrated over once. Blob disk paths are always
- // recomputed from this root at load time (see mod/sharedspace/persistence.go),
- // so relocating the tree is safe.
- func ssResolvePersistRoot() string {
- persistRoot := filepath.Join(*root_directory, "sharedspace")
- legacyRoot := filepath.Join("system", "sharedspace")
- if utils.FileExists(legacyRoot) && !utils.FileExists(persistRoot) {
- os.MkdirAll(filepath.Dir(persistRoot), 0775)
- if err := os.Rename(legacyRoot, persistRoot); err != nil {
- //e.g. the user root sits on another mount - keep the data usable
- systemWideLogger.PrintAndLog("SharedSpace", "Could not move shared space storage to "+persistRoot+", keeping "+legacyRoot, err)
- return legacyRoot
- }
- systemWideLogger.PrintAndLog("SharedSpace", "Shared space storage migrated from "+legacyRoot+" to "+persistRoot, nil)
- }
- return persistRoot
- }
- // SharedSpaceInit creates the system-wide shared collaboration space manager
- // and wires up its HTTP / WebSocket endpoints and the admin surface.
- // Must be initiated before MeetRoomInit and AGIInit (see startup.go).
- func SharedSpaceInit() {
- sharedSpaceManager = sharedspace.NewManagerWithOptions(sharedspace.ManagerOptions{
- PersistentRoot: ssResolvePersistRoot(),
- Database: sysdb,
- MaxUpload: ssConfInt64(ssConfMaxUpload, sharedspace.DefaultMaxUpload),
- DefaultMaxItems: int(ssConfInt64(ssConfMaxItems, sharedspace.DefaultMaxItems)),
- })
- sharedSpaceManager.SetAllowPersistent(ssConfBool(ssConfAllowPersistent, true))
- //Retention: periodically remove persistent spaces idle for longer than
- //the admin-configured retention window (0 disables the sweep)
- go func() {
- ticker := time.NewTicker(ssSweepInterval)
- defer ticker.Stop()
- for range ticker.C {
- retentionDays := ssConfInt64(ssConfRetentionDays, 0)
- if retentionDays > 0 {
- sharedSpaceManager.SweepStaleSpaces(time.Duration(retentionDays) * 24 * time.Hour)
- }
- }
- }()
- //User endpoints: login-only universal router. Spaces carry their own
- //access control (open capability / public / private membership), which
- //is the authorization layer for everything below.
- router := prout.NewModuleRouter(prout.RouterOption{
- ModuleName: "",
- AdminOnly: false,
- UserHandler: userHandler,
- DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
- errorHandlePermissionDenied(w, r)
- },
- })
- //Create a new space; the creator becomes the owner.
- router.HandleFunc("/system/sharedspace/create", func(w http.ResponseWriter, r *http.Request) {
- userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
- if err != nil {
- utils.SendErrorResponse(w, "Not logged in")
- return
- }
- name, _ := utils.PostPara(r, "name")
- access, _ := utils.PostPara(r, "access")
- persistent, _ := utils.PostPara(r, "persistent")
- space, err := sharedSpaceManager.CreateSpaceWithOptions(userinfo.Username, name, sharedspace.SpaceOptions{
- Access: access,
- Persistent: persistent == "true",
- })
- if err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- js := mrMarshalOrDrop(ssDescribeSpace(space, userinfo.Username))
- utils.SendJSONResponse(w, string(js))
- })
- //Delete a space; space managers only.
- router.HandleFunc("/system/sharedspace/delete", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- if !space.CanManage(username) {
- utils.SendErrorResponse(w, "Permission denied")
- return
- }
- ssCloseSpace(space.ID)
- utils.SendOK(w)
- })
- //Describe one space.
- router.HandleFunc("/system/sharedspace/info", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, false)
- if !ok {
- return
- }
- desc := ssDescribeSpace(space, username)
- //The member list is only revealed to members and managers
- if _, isMember := space.Role(username); isMember || space.CanManage(username) {
- desc["memberlist"] = space.Members()
- }
- js := mrMarshalOrDrop(desc)
- utils.SendJSONResponse(w, string(js))
- })
- //List the spaces the caller has joined (or owns).
- router.HandleFunc("/system/sharedspace/list", func(w http.ResponseWriter, r *http.Request) {
- userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
- if err != nil {
- utils.SendErrorResponse(w, "Not logged in")
- return
- }
- joined := sharedSpaceManager.ListSpacesByMember(userinfo.Username)
- list := make([]map[string]interface{}, 0, len(joined))
- for _, space := range joined {
- list = append(list, ssDescribeSpace(space, userinfo.Username))
- }
- js := mrMarshalOrDrop(list)
- utils.SendJSONResponse(w, string(js))
- })
- //List every public space (the discovery directory).
- router.HandleFunc("/system/sharedspace/listpublic", func(w http.ResponseWriter, r *http.Request) {
- userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
- if err != nil {
- utils.SendErrorResponse(w, "Not logged in")
- return
- }
- public := sharedSpaceManager.ListPublicSpaces()
- list := make([]map[string]interface{}, 0, len(public))
- for _, space := range public {
- list = append(list, ssDescribeSpace(space, userinfo.Username))
- }
- js := mrMarshalOrDrop(list)
- utils.SendJSONResponse(w, string(js))
- })
- //Self-join a public (or open) space.
- router.HandleFunc("/system/sharedspace/join", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- if err := space.JoinPublic(username); err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- utils.SendOK(w)
- })
- //Leave a space.
- router.HandleFunc("/system/sharedspace/leave", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- if err := space.RemoveMember(username, username); err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- utils.SendOK(w)
- })
- //Invite a user into the space; managers only.
- router.HandleFunc("/system/sharedspace/members/add", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- target, err := utils.PostPara(r, "username")
- if err != nil {
- utils.SendErrorResponse(w, "Missing username")
- return
- }
- role, _ := utils.PostPara(r, "role")
- if role == "" {
- role = sharedspace.RoleMember
- }
- //The invitee must be a real user on this host
- if _, err := userHandler.GetUserInfoFromUsername(target); err != nil {
- utils.SendErrorResponse(w, "User not found")
- return
- }
- if err := space.AddMember(username, target, role); err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- utils.SendOK(w)
- })
- //Remove a member; managers (or the member themselves) only.
- router.HandleFunc("/system/sharedspace/members/remove", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- target, err := utils.PostPara(r, "username")
- if err != nil {
- utils.SendErrorResponse(w, "Missing username")
- return
- }
- if err := space.RemoveMember(username, target); err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- utils.SendOK(w)
- })
- //Change a member's role; managers only.
- router.HandleFunc("/system/sharedspace/members/role", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- target, err := utils.PostPara(r, "username")
- if err != nil {
- utils.SendErrorResponse(w, "Missing username")
- return
- }
- role, err := utils.PostPara(r, "role")
- if err != nil {
- utils.SendErrorResponse(w, "Missing role")
- return
- }
- if err := space.SetMemberRole(username, target, role); err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- utils.SendOK(w)
- })
- //Change the access mode; managers only.
- router.HandleFunc("/system/sharedspace/access", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- access, err := utils.PostPara(r, "access")
- if err != nil {
- utils.SendErrorResponse(w, "Missing access mode")
- return
- }
- if err := space.SetAccess(username, access); err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- utils.SendOK(w)
- })
- //Set (or with an empty value delete) a metadata entry; managers only.
- router.HandleFunc("/system/sharedspace/meta", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- key, err := utils.PostPara(r, "key")
- if err != nil {
- utils.SendErrorResponse(w, "Missing key")
- return
- }
- value, _ := utils.PostPara(r, "value")
- if err := space.SetMeta(username, key, value); err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- utils.SendOK(w)
- })
- //List the items in a space.
- router.HandleFunc("/system/sharedspace/items", func(w http.ResponseWriter, r *http.Request) {
- _, space, ok := ssResolveSpaceRequest(w, r, false)
- if !ok {
- return
- }
- items := space.Items()
- list := make([]map[string]interface{}, 0, len(items))
- for _, item := range items {
- list = append(list, ssDescribeItem(item))
- }
- js := mrMarshalOrDrop(list)
- utils.SendJSONResponse(w, string(js))
- })
- //Post a text snippet.
- router.HandleFunc("/system/sharedspace/addtext", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- text, err := utils.PostPara(r, "text")
- if err != nil {
- utils.SendErrorResponse(w, "Missing text")
- return
- }
- item, err := space.AddText(username, text, "http")
- if err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- js := mrMarshalOrDrop(map[string]interface{}{"itemid": item.ID})
- utils.SendJSONResponse(w, string(js))
- })
- //Upload a file / image into the space.
- router.HandleFunc("/system/sharedspace/upload", func(w http.ResponseWriter, r *http.Request) {
- userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
- if err != nil {
- utils.SendErrorResponse(w, "Not logged in")
- return
- }
- if err := r.ParseMultipartForm(32 << 20); err != nil {
- utils.SendErrorResponse(w, "Invalid upload")
- return
- }
- space, exists := sharedSpaceManager.GetSpace(r.FormValue("spaceid"))
- if !exists || !space.CanPost(userinfo.Username) {
- utils.SendErrorResponse(w, "Space not found")
- return
- }
- file, header, err := r.FormFile("file")
- if err != nil {
- utils.SendErrorResponse(w, "Missing file")
- return
- }
- defer file.Close()
- itemType := sharedspace.ItemTypeFile
- if sharedspace.IsImageName(header.Filename) {
- itemType = sharedspace.ItemTypeImage
- }
- item, err := space.SaveBlob(itemType, header.Filename, userinfo.Username, "http", file, sharedSpaceManager.MaxUpload())
- if err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- js := mrMarshalOrDrop(map[string]interface{}{
- "itemid": item.ID,
- "name": item.Name,
- "size": item.Size,
- })
- utils.SendJSONResponse(w, string(js))
- })
- //Download an item blob. inline=1 serves raster images for in-browser
- //display; everything else (notably SVG, which can carry scripts) keeps
- //the attachment disposition.
- router.HandleFunc("/system/sharedspace/download", func(w http.ResponseWriter, r *http.Request) {
- _, space, ok := ssResolveSpaceRequest(w, r, false)
- if !ok {
- return
- }
- itemID, err := utils.GetPara(r, "itemid")
- if err != nil {
- utils.SendErrorResponse(w, "Missing item ID")
- return
- }
- item, exists := space.GetItem(itemID)
- if !exists || item.DiskPath == "" {
- http.NotFound(w, r)
- return
- }
- f, err := os.Open(item.DiskPath)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- defer f.Close()
- //Serve with the original name; the ASCII fallback strips anything
- //that could break the header, the RFC 5987 form keeps unicode names.
- fallback := strings.Map(func(c rune) rune {
- if c < 32 || c == '"' || c == '\\' || c > 126 {
- return '_'
- }
- return c
- }, item.Name)
- disposition := "attachment"
- if r.URL.Query().Get("inline") == "1" && sharedspace.IsImageName(item.Name) {
- disposition = "inline"
- }
- w.Header().Set("Content-Disposition", disposition+"; filename=\""+fallback+"\"; filename*=UTF-8''"+url.PathEscape(item.Name))
- w.Header().Set("X-Content-Type-Options", "nosniff")
- if ctype := mime.TypeByExtension(strings.ToLower(filepathExt(item.Name))); ctype != "" {
- w.Header().Set("Content-Type", ctype)
- } else {
- w.Header().Set("Content-Type", "application/octet-stream")
- }
- http.ServeContent(w, r, "", time.Now(), f)
- })
- //Remove an item; uploader, space managers or the system.
- router.HandleFunc("/system/sharedspace/removeitem", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- itemID, err := utils.PostPara(r, "itemid")
- if err != nil {
- utils.SendErrorResponse(w, "Missing item ID")
- return
- }
- if err := space.RemoveItem(itemID, username); err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- utils.SendOK(w)
- })
- //Create a collaborative document.
- router.HandleFunc("/system/sharedspace/doc/create", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- name, _ := utils.PostPara(r, "name")
- doc, err := space.CreateDoc(username, name)
- if err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- js := mrMarshalOrDrop(ssDescribeDoc(doc, true))
- utils.SendJSONResponse(w, string(js))
- })
- //List documents (without contents).
- router.HandleFunc("/system/sharedspace/doc/list", func(w http.ResponseWriter, r *http.Request) {
- _, space, ok := ssResolveSpaceRequest(w, r, false)
- if !ok {
- return
- }
- docs := space.ListDocs()
- list := make([]map[string]interface{}, 0, len(docs))
- for _, doc := range docs {
- list = append(list, ssDescribeDoc(doc, false))
- }
- js := mrMarshalOrDrop(list)
- utils.SendJSONResponse(w, string(js))
- })
- //Fetch one document with content (also the conflict recovery path).
- router.HandleFunc("/system/sharedspace/doc/get", func(w http.ResponseWriter, r *http.Request) {
- _, space, ok := ssResolveSpaceRequest(w, r, false)
- if !ok {
- return
- }
- docID, err := utils.GetPara(r, "docid")
- if err != nil {
- utils.SendErrorResponse(w, "Missing document ID")
- return
- }
- doc, exists := space.GetDoc(docID)
- if !exists {
- utils.SendErrorResponse(w, "Document not found")
- return
- }
- js := mrMarshalOrDrop(ssDescribeDoc(doc, true))
- utils.SendJSONResponse(w, string(js))
- })
- //Compare-and-swap document update over HTTP.
- router.HandleFunc("/system/sharedspace/doc/update", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- docID, err := utils.PostPara(r, "docid")
- if err != nil {
- utils.SendErrorResponse(w, "Missing document ID")
- return
- }
- baseRevStr, err := utils.PostPara(r, "baserev")
- if err != nil {
- utils.SendErrorResponse(w, "Missing base revision")
- return
- }
- baseRev, err := strconv.ParseInt(baseRevStr, 10, 64)
- if err != nil {
- utils.SendErrorResponse(w, "Invalid base revision")
- return
- }
- content, _ := utils.PostPara(r, "content")
- doc, err := space.UpdateDoc(username, docID, baseRev, content)
- if err == sharedspace.ErrRevisionConflict {
- //Report the current revision so the client can re-fetch + rebase
- current, _ := space.GetDoc(docID)
- revision := int64(0)
- if current != nil {
- revision = current.Revision
- }
- js := mrMarshalOrDrop(map[string]interface{}{
- "error": "revision conflict",
- "revision": revision,
- })
- utils.SendJSONResponse(w, string(js))
- return
- }
- if err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- js := mrMarshalOrDrop(map[string]interface{}{
- "ok": true,
- "revision": doc.Revision,
- })
- utils.SendJSONResponse(w, string(js))
- })
- //Delete a document; creator or space managers.
- router.HandleFunc("/system/sharedspace/doc/delete", func(w http.ResponseWriter, r *http.Request) {
- username, space, ok := ssResolveSpaceRequest(w, r, true)
- if !ok {
- return
- }
- docID, err := utils.PostPara(r, "docid")
- if err != nil {
- utils.SendErrorResponse(w, "Missing document ID")
- return
- }
- if err := space.DeleteDoc(username, docID); err != nil {
- utils.SendErrorResponse(w, err.Error())
- return
- }
- utils.SendOK(w)
- })
- //WebSocket: realtime bidirectional exchange on a space.
- router.HandleFunc("/system/sharedspace/ws", ssHandleWebSocket)
- //Admin surface: System Settings > Shared Spaces
- adminRouter := prout.NewModuleRouter(prout.RouterOption{
- ModuleName: "System Settings",
- AdminOnly: true,
- UserHandler: userHandler,
- DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
- errorHandlePermissionDenied(w, r)
- },
- })
- //Enumerate every space with usage figures.
- adminRouter.HandleFunc("/system/sharedspace/admin/list", func(w http.ResponseWriter, r *http.Request) {
- all := sharedSpaceManager.ListSpaces()
- list := make([]map[string]interface{}, 0, len(all))
- for _, space := range all {
- desc := ssDescribeSpace(space, "")
- desc["subscribers"] = space.Channel().Count()
- desc["diskusage"] = sharedSpaceManager.SpaceDiskUsage(space.ID)
- list = append(list, desc)
- }
- js := mrMarshalOrDrop(list)
- utils.SendJSONResponse(w, string(js))
- })
- //Delete any space with system authority.
- adminRouter.HandleFunc("/system/sharedspace/admin/delete", func(w http.ResponseWriter, r *http.Request) {
- spaceID, err := utils.PostPara(r, "spaceid")
- if err != nil {
- utils.SendErrorResponse(w, "Missing space ID")
- return
- }
- if _, exists := sharedSpaceManager.GetSpace(spaceID); !exists {
- utils.SendErrorResponse(w, "Space not found")
- return
- }
- ssCloseSpace(spaceID)
- utils.SendOK(w)
- })
- //Current admin configuration.
- adminRouter.HandleFunc("/system/sharedspace/admin/config", func(w http.ResponseWriter, r *http.Request) {
- js := mrMarshalOrDrop(map[string]interface{}{
- "maxupload": sharedSpaceManager.MaxUpload(),
- "maxitems": sharedSpaceManager.DefaultItemLimit(),
- "allowpersistent": sharedSpaceManager.PersistenceAvailable(),
- "retentiondays": ssConfInt64(ssConfRetentionDays, 0),
- "spaces": sharedSpaceManager.SpaceCount(),
- })
- utils.SendJSONResponse(w, string(js))
- })
- //Update the admin configuration (persisted; applied live).
- adminRouter.HandleFunc("/system/sharedspace/admin/setconfig", func(w http.ResponseWriter, r *http.Request) {
- if maxUpload, err := utils.PostPara(r, "maxupload"); err == nil {
- if value, err := strconv.ParseInt(maxUpload, 10, 64); err == nil && value > 0 {
- sysdb.Write(ssDBTable, ssConfMaxUpload, value)
- sharedSpaceManager.SetMaxUpload(value)
- }
- }
- if maxItems, err := utils.PostPara(r, "maxitems"); err == nil {
- if value, err := strconv.Atoi(maxItems); err == nil && value > 0 {
- sysdb.Write(ssDBTable, ssConfMaxItems, int64(value))
- sharedSpaceManager.SetDefaultItemLimit(value)
- }
- }
- if allowPersistent, err := utils.PostPara(r, "allowpersistent"); err == nil {
- enabled := allowPersistent == "true"
- sysdb.Write(ssDBTable, ssConfAllowPersistent, enabled)
- sharedSpaceManager.SetAllowPersistent(enabled)
- }
- if retentionDays, err := utils.PostPara(r, "retentiondays"); err == nil {
- if value, err := strconv.ParseInt(retentionDays, 10, 64); err == nil && value >= 0 {
- sysdb.Write(ssDBTable, ssConfRetentionDays, value)
- }
- }
- utils.SendOK(w)
- })
- //Settings tile in System Settings
- registerSetting(settingModule{
- Name: "Shared Spaces",
- Desc: "Collaboration spaces: storage, limits and cleanup",
- IconPath: "SystemAO/system_setting/img/module.svg",
- Group: "Advance",
- StartDir: "SystemAO/sharedspace/spaces.html",
- RequireAdmin: true,
- })
- }
- // ssHandleWebSocket upgrades the request and joins the caller to the
- // space's realtime channel.
- func ssHandleWebSocket(w http.ResponseWriter, r *http.Request) {
- userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
- if err != nil {
- http.Error(w, "Not logged in", http.StatusUnauthorized)
- return
- }
- username := userinfo.Username
- space, exists := sharedSpaceManager.GetSpace(r.URL.Query().Get("spaceid"))
- if !exists || !space.CanRead(username) {
- http.Error(w, "Space not found", http.StatusForbidden)
- return
- }
- conn, err := ssUpgrader.Upgrade(w, r, nil)
- if err != nil {
- return
- }
- conn.SetReadLimit(ssMaxSocketFrame)
- //Liveness: a client that goes silent (no frames and no pong replies)
- //past ssReadTimeout is dropped so it does not linger as a ghost.
- conn.SetReadDeadline(time.Now().Add(ssReadTimeout))
- conn.SetPongHandler(func(string) error {
- conn.SetReadDeadline(time.Now().Add(ssReadTimeout))
- return nil
- })
- channel := space.Channel()
- sub, err := channel.Join(username)
- if err != nil {
- conn.Close()
- return
- }
- //Fan space mutations out to this channel as protocol frames. The fixed
- //key makes re-registration on every connection idempotent.
- space.SubscribeEvents("wstransport", ssBroadcastSpaceEvent(space))
- //Writer: drain the send buffer until it is closed, interleaving
- //keepalive pings, then hang up.
- go func() {
- pinger := time.NewTicker(ssPingInterval)
- defer pinger.Stop()
- defer conn.Close()
- for {
- select {
- case msg, ok := <-sub.Send:
- if !ok {
- return
- }
- conn.SetWriteDeadline(time.Now().Add(ssWriteTimeout))
- if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
- return
- }
- case <-pinger.C:
- conn.SetWriteDeadline(time.Now().Add(ssWriteTimeout))
- if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
- return
- }
- }
- }
- }()
- //Welcome frame: own identity, space descriptor and current peers.
- peers := []map[string]interface{}{}
- for _, peer := range channel.Subscribers() {
- if peer.ID == sub.ID {
- continue
- }
- peers = append(peers, map[string]interface{}{"subid": peer.ID, "username": peer.Username})
- }
- channel.SendTo(sub.ID, mrMarshalOrDrop(map[string]interface{}{
- "type": "welcome",
- "subid": sub.ID,
- "username": username,
- "space": ssDescribeSpace(space, username),
- "peers": peers,
- }))
- //Announce the newcomer to everyone else.
- channel.Broadcast(mrMarshalOrDrop(map[string]interface{}{
- "type": "peer-join",
- "peer": map[string]interface{}{"subid": sub.ID, "username": username},
- }), sub.ID)
- defer func() {
- channel.Leave(sub.ID)
- channel.Broadcast(mrMarshalOrDrop(map[string]interface{}{
- "type": "peer-leave",
- "subid": sub.ID,
- "username": username,
- }), -1)
- }()
- sendError := func(message string) {
- channel.SendTo(sub.ID, mrMarshalOrDrop(map[string]interface{}{
- "type": "error",
- "error": message,
- }))
- }
- for {
- _, raw, err := conn.ReadMessage()
- if err != nil {
- return
- }
- conn.SetReadDeadline(time.Now().Add(ssReadTimeout))
- var frame struct {
- Type string `json:"type"`
- To int `json:"to"`
- Data json.RawMessage `json:"data"`
- Text string `json:"text"`
- DocID string `json:"docid"`
- BaseRev int64 `json:"baserev"`
- Content string `json:"content"`
- }
- if json.Unmarshal(raw, &frame) != nil {
- continue
- }
- switch frame.Type {
- case "signal":
- //Ephemeral point-to-point relay (WebRTC SDP/ICE and friends)
- channel.SendTo(frame.To, mrMarshalOrDrop(map[string]interface{}{
- "type": "signal",
- "from": sub.ID,
- "data": frame.Data,
- }))
- case "broadcast":
- //Ephemeral application fan-out to every other subscriber
- channel.Broadcast(mrMarshalOrDrop(map[string]interface{}{
- "type": "broadcast",
- "from": sub.ID,
- "username": username,
- "data": frame.Data,
- }), sub.ID)
- case "chat":
- text := frame.Text
- if strings.TrimSpace(text) == "" {
- continue
- }
- if runes := []rune(text); len(runes) > ssMaxChatLength {
- text = string(runes[:ssMaxChatLength])
- }
- //Persisted: delivery to every subscriber happens through the
- //item-added event fan-out (no double send)
- if _, err := space.AddText(username, text, "ws"); err != nil {
- sendError(err.Error())
- }
- case "doc-update":
- _, err := space.UpdateDoc(username, frame.DocID, frame.BaseRev, frame.Content)
- if err == sharedspace.ErrRevisionConflict {
- current, _ := space.GetDoc(frame.DocID)
- revision := int64(0)
- if current != nil {
- revision = current.Revision
- }
- channel.SendTo(sub.ID, mrMarshalOrDrop(map[string]interface{}{
- "type": "doc-conflict",
- "docid": frame.DocID,
- "revision": revision,
- }))
- } else if err != nil {
- sendError(err.Error())
- }
- //Success needs no direct reply: the doc-updated event fan-out
- //delivers the accepted patch (with the new revision) to
- //everyone, the sender included.
- case "ping":
- channel.SendTo(sub.ID, []byte(`{"type":"pong"}`))
- }
- }
- }
|