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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ anytype space join <invite-link>
anytype space leave <space-id>
```

#### Self-hosted networks
- Set a default network for joins: `anytype config set networkId <network-id>`
- Join with a self-hosted invite link: `anytype space join https://<host>/<cid>#<key>`
- Override per-call if needed: `anytype space join --network <network-id> https://<host>/<cid>#<key>`

## Development

### Project Structure
Expand Down
9 changes: 9 additions & 0 deletions cmd/config/get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ func NewGetCmd() *cobra.Command {
if len(args) == 0 {
accountId, _ := config.GetAccountIdFromConfig()
techSpaceId, _ := config.GetTechSpaceIdFromConfig()
networkId, _ := config.GetNetworkIdFromConfig()

if accountId != "" {
output.Info("accountId: %s", accountId)
}
if techSpaceId != "" {
output.Info("techSpaceId: %s", techSpaceId)
}
if networkId != "" {
output.Info("networkId: %s", networkId)
}
return nil
}

Expand All @@ -37,6 +41,11 @@ func NewGetCmd() *cobra.Command {
if techSpaceId != "" {
output.Info(techSpaceId)
}
case "networkId":
networkId, _ := config.GetNetworkIdFromConfig()
if networkId != "" {
output.Info(networkId)
}
default:
return output.Error("unknown config key: %s", key)
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/config/set/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ func NewSetCmd() *cobra.Command {
if err := config.SetTechSpaceIdToConfig(value); err != nil {
return output.Error("Failed to set tech space Id: %w", err)
}
case "networkId":
if err := config.SetNetworkIdToConfig(value); err != nil {
return output.Error("Failed to set network Id: %w", err)
}
default:
return output.Error("unknown config key: %s", key)
}
Expand Down
86 changes: 66 additions & 20 deletions cmd/space/join/join.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package join

import (
"fmt"
"net/url"
"strings"

Expand All @@ -22,44 +23,48 @@ func NewJoinCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "join <invite-link>",
Short: "Join a space",
Long: "Join a space using an invite link (https://invite.any.coop/...)",
Long: "Join a space using an invite link (https://<host>/<cid>#<key>)",
Args: cmdutil.ExactArgs(1, "cannot join space: invite-link argument required"),
RunE: func(cmd *cobra.Command, args []string) error {
input := args[0]
var spaceId string

if networkId == "" {
networkId = config.AnytypeNetworkAddress
if storedNetworkId, err := config.GetNetworkIdFromConfig(); err == nil && storedNetworkId != "" {
networkId = storedNetworkId
} else {
networkId = config.AnytypeNetworkAddress
}
}

if strings.HasPrefix(input, "https://invite.any.coop/") {
u, err := url.Parse(input)
if inviteCid == "" || inviteFileKey == "" {
parsedCid, parsedKey, err := parseInviteLinkParts(input)
if err != nil {
return output.Error("invalid invite link: %w", err)
}

path := strings.TrimPrefix(u.Path, "/")
if path == "" {
return output.Error("invite link missing Cid")
if inviteCid == "" {
if parsedCid == "" {
return output.Error("invalid invite link: missing Cid in path")
}
inviteCid = parsedCid
}
inviteCid = path

inviteFileKey = u.Fragment
if inviteFileKey == "" {
return output.Error("invite link missing key (should be after #)")
}

info, err := core.ViewSpaceInvite(inviteCid, inviteFileKey)
if err != nil {
return output.Error("Failed to view invite: %w", err)
if parsedKey == "" {
return output.Error("invalid invite link: missing key (should be after #)")
}
inviteFileKey = parsedKey
}
}

output.Info("Joining space '%s' created by %s...", info.SpaceName, info.CreatorName)
spaceId = info.SpaceId
} else {
return output.Error("invalid invite link format, expected: https://invite.any.coop/{cid}#{key}")
info, err := core.ViewSpaceInvite(inviteCid, inviteFileKey)
if err != nil {
return output.Error("Failed to view invite: %w", err)
}

output.Info("Joining space '%s' created by %s...", info.SpaceName, info.CreatorName)
spaceId = info.SpaceId

if err := core.JoinSpace(networkId, spaceId, inviteCid, inviteFileKey); err != nil {
return output.Error("Failed to join space: %w", err)
}
Expand All @@ -75,3 +80,44 @@ func NewJoinCmd() *cobra.Command {

return cmd
}

func parseInviteLinkParts(input string) (string, string, error) {
u, err := url.Parse(input)
if err != nil {
return "", "", fmt.Errorf("failed to parse: %w", err)
}

if u.Scheme != "https" && u.Scheme != "http" {
return "", "", fmt.Errorf("unsupported scheme %q (expected http or https)", u.Scheme)
Comment on lines +90 to +91
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code allows both HTTP and HTTPS schemes for invite links, which could pose a security risk. Invite links may contain sensitive information (CID and key), and allowing HTTP means this data could be transmitted in plaintext over unencrypted connections. Consider adding a warning when HTTP is used, restricting HTTP to localhost/development environments only, or requiring HTTPS for production use to protect sensitive invite data.

Suggested change
if u.Scheme != "https" && u.Scheme != "http" {
return "", "", fmt.Errorf("unsupported scheme %q (expected http or https)", u.Scheme)
// Only allow https for invite links in general.
// Permit http only for localhost/loopback to support development environments.
if u.Scheme != "https" {
isLocalhost := u.Host == "localhost" || strings.HasPrefix(u.Host, "127.0.0.1") || u.Host == "[::1]"
if !(u.Scheme == "http" && isLocalhost) {
return "", "", fmt.Errorf("unsupported scheme %q (expected https, or http only for localhost)", u.Scheme)
}

Copilot uses AI. Check for mistakes.
}

if u.Host == "" {
return "", "", fmt.Errorf("invite link missing host")
}

var cid string
if path := strings.Trim(u.Path, "/"); path != "" {
parts := strings.Split(path, "/")
cid = parts[len(parts)-1]
}

key := u.Fragment

return cid, key, nil
}

func parseInviteLink(input string) (string, string, error) {
// Convenience wrapper that enforces both cid and key presence.
// The command path uses parseInviteLinkParts to allow partial override via flags.
cid, key, err := parseInviteLinkParts(input)
if err != nil {
return "", "", err
}
if cid == "" {
return "", "", fmt.Errorf("invite link missing Cid in path")
}
if key == "" {
return "", "", fmt.Errorf("invite link missing key (should be after #)")
}
return cid, key, nil
}
74 changes: 74 additions & 0 deletions cmd/space/join/join_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package join

import "testing"

func TestParseInviteLink(t *testing.T) {
tests := []struct {
name string
input string
wantCid string
wantKey string
expectErr bool
}{
{
name: "default host",
input: "https://invite.any.coop/abc123#filekey",
wantCid: "abc123",
wantKey: "filekey",
},
{
name: "custom host and nested path",
input: "https://selfhost.local/invites/space-1#k1",
wantCid: "space-1",
wantKey: "k1",
},
{
name: "missing fragment",
input: "https://selfhost.local/invites/space-1",
expectErr: true,
},
{
name: "missing cid",
input: "https://selfhost.local/#k1",
expectErr: true,
},
{
name: "unsupported scheme",
input: "ftp://selfhost.local/space#k1",
expectErr: true,
},
{
name: "missing host",
input: "https:///space#k1",
expectErr: true,
},
{
name: "http scheme allowed",
input: "http://selfhost.local/space#k1",
wantCid: "space",
wantKey: "k1",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
cid, key, err := parseInviteLink(tt.input)
if tt.expectErr {
if err == nil {
t.Fatalf("expected error, got none")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cid != tt.wantCid {
t.Fatalf("cid = %s, want %s", cid, tt.wantCid)
}
if key != tt.wantKey {
t.Fatalf("key = %s, want %s", key, tt.wantKey)
}
})
}
}
9 changes: 9 additions & 0 deletions core/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Config struct {
// WARNING: This is insecure and should only be used on headless servers
AccountKey string `json:"accountKey,omitempty"`
SessionToken string `json:"sessionToken,omitempty"`
NetworkId string `json:"networkId,omitempty"`
}

var (
Expand Down Expand Up @@ -118,6 +119,14 @@ func (cm *ConfigManager) SetTechSpaceId(techSpaceId string) error {
return cm.Save()
}

func (cm *ConfigManager) SetNetworkId(networkId string) error {
cm.mu.Lock()
cm.config.NetworkId = networkId
cm.mu.Unlock()

return cm.Save()
}

func (cm *ConfigManager) SetSessionToken(token string) error {
cm.mu.Lock()
cm.config.SessionToken = token
Expand Down
22 changes: 22 additions & 0 deletions core/config/config_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ func SetTechSpaceIdToConfig(techSpaceId string) error {
return configMgr.SetTechSpaceId(techSpaceId)
}

func GetNetworkIdFromConfig() (string, error) {
configMgr := GetConfigManager()
if err := configMgr.Load(); err != nil {
return "", fmt.Errorf("failed to load config: %w", err)
}

cfg := configMgr.Get()
if cfg.NetworkId == "" {
return "", fmt.Errorf("no network Id found in config")
}

return cfg.NetworkId, nil
}

func SetNetworkIdToConfig(networkId string) error {
configMgr := GetConfigManager()
if err := configMgr.Load(); err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
return configMgr.SetNetworkId(networkId)
}

func LoadStoredConfig() (*Config, error) {
configMgr := GetConfigManager()
if err := configMgr.Load(); err != nil {
Expand Down
Loading