This repository has been archived on 2024-07-28. You can view files and clone it, but cannot push or open issues or pull requests.
Files
cairde/cairde.go

615 lines
12 KiB
Go

package main
import (
"crypto/rand"
"encoding/base64"
"flag"
"fmt"
"log"
mrand "math/rand"
"os"
"os/exec"
"os/user"
"path"
"path/filepath"
"strings"
"time"
"cwtch.im/cwtch/app"
"cwtch.im/cwtch/model/attr"
"cwtch.im/cwtch/model/constants"
"cwtch.im/cwtch/protocol/connections"
"cwtch.im/cwtch/settings"
"git.openprivacy.ca/openprivacy/connectivity"
"git.openprivacy.ca/openprivacy/connectivity/tor"
openPrivacyLog "git.openprivacy.ca/openprivacy/log"
_ "github.com/mutecomm/go-sqlcipher/v4"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
offline = iota
connecting
connected
help = `cairde [options]
A text-based user interface for metadata resistant online chat.
Options:
-h output help
`
cmdHelp = `/clear
/connect
/help
/profile create <name> <password> <password>
/profile info
/profile unlock <password>
/quit`
)
func main() {
var helpFlag bool
flag.BoolVar(&helpFlag, "h", false, "output help")
flag.Parse()
if helpFlag {
fmt.Print(help)
os.Exit(0)
}
// NOTE(d1): pending https://git.coopcloud.tech/decentral1se/cairde/issues/1
_, err := exec.LookPath("tor")
if err != nil {
log.Fatal("could not find 'tor' command, is it installed?")
}
f, err := tea.LogToFile("cairde.log", "debug")
if err != nil {
log.Fatal(err)
}
defer f.Close()
filelogger, err := openPrivacyLog.NewFile(openPrivacyLog.LevelInfo, "cwtch.log")
if err == nil {
openPrivacyLog.SetStd(filelogger)
}
user, err := user.Current()
if err != nil {
log.Fatalf("unable to determine current user: %s", err)
}
p := tea.NewProgram(
newModel(user.Username, user.HomeDir),
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
if err := p.Start(); err != nil {
log.Fatal(err)
}
}
var (
mainStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder())
viewportStyle = lipgloss.NewStyle()
profileNameButtonStyle = lipgloss.NewStyle()
channelBarStyle = lipgloss.NewStyle()
inputStyle = lipgloss.NewStyle()
)
type profile struct {
name string
onion string
}
type model struct {
username string
userDir string
connState int
app app.Application
acn connectivity.ACN
width int
height int
viewport viewport.Model
profiles []profile
profileState int
input textinput.Model
hiddenInput string
statusBuffer chan string
}
func newModel(username, homeDir string) model {
input := textinput.New()
input.Prompt = ""
input.SetCursorMode(textinput.CursorStatic)
input.Focus()
return model{
username: username,
userDir: path.Join(homeDir, "/.cairde/"),
connState: offline,
input: input,
statusBuffer: make(chan string),
}
}
type errMsg struct{ Err error }
func (e errMsg) Error() string {
return e.Err.Error()
}
type appInitialisedMsg struct {
app app.Application
acn connectivity.ACN
}
func (m model) initApp() tea.Cmd {
return func() tea.Msg {
mrand.Seed(int64(time.Now().Nanosecond()))
port := mrand.Intn(1000) + 9600
controlPort := port + 1
key := make([]byte, 64)
_, err := rand.Read(key)
if err != nil {
return errMsg{Err: fmt.Errorf("unable to generate control port password: %s", err)}
}
if err := os.MkdirAll(path.Join(m.userDir, "/.tor", "tor"), 0700); err != nil {
return errMsg{Err: fmt.Errorf("unable to create tor directory: %s", err)}
}
if err := os.MkdirAll(path.Join(m.userDir, "profiles"), 0700); err != nil {
return errMsg{Err: fmt.Errorf("unable to create profiles directory: %s", err)}
}
if err := tor.NewTorrc().
WithSocksPort(port).
WithOnionTrafficOnly().
WithControlPort(controlPort).
WithHashedPassword(base64.StdEncoding.EncodeToString(key)).
Build(filepath.Join(m.userDir, ".tor", "tor", "torrc")); err != nil {
return errMsg{Err: fmt.Errorf("unable to initialise torrc builder: %s", err)}
}
m.statusBuffer <- "initialising Tor ACN..."
acn, err := tor.NewTorACNWithAuth(
path.Join(m.userDir, "/.tor"),
"",
path.Join(m.userDir, "/.tor", "data"),
controlPort,
tor.HashedPasswordAuthenticator{
Password: base64.StdEncoding.EncodeToString(key),
},
)
if err != nil {
return errMsg{Err: fmt.Errorf("unable to bootstrap tor: %s", err)}
}
m.statusBuffer <- "waiting for Tor ACN to bootstrap..."
if err := acn.WaitTillBootstrapped(); err != nil {
return errMsg{Err: fmt.Errorf("unable to initialise tor: %s", err)}
}
settingsFile, err := settings.InitGlobalSettingsFile(m.userDir, "")
if err != nil {
return errMsg{Err: fmt.Errorf("unable to initialise settings: %s", err)}
}
gSettings := settingsFile.ReadGlobalSettings()
gSettings.ExperimentsEnabled = false
gSettings.DownloadPath = "./"
settingsFile.WriteGlobalSettings(gSettings)
app := app.NewApp(acn, m.userDir, settingsFile)
engineHooks := connections.DefaultEngineHooks{}
app.InstallEngineHooks(engineHooks)
m.statusBuffer <- "the Tor ACN is up"
return appInitialisedMsg{
app: app,
acn: acn,
}
}
}
type statusMsg struct{ msg string }
func (m model) handleStatusMessage() tea.Cmd {
return func() tea.Msg {
return statusMsg{msg: <-m.statusBuffer}
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(
textinput.Blink,
m.handleStatusMessage(),
)
}
func (m model) isConnecting() bool {
if m.connState == connecting {
return true
}
return false
}
func (m model) isOffline() bool {
if m.connState == offline {
return true
}
return false
}
func (m model) shutdown() bool {
if m.isConnecting() {
return false
}
if m.isOffline() {
return true
}
m.app.Shutdown()
m.acn.Close()
close(m.statusBuffer)
return true
}
type cmdMsg struct {
output string
}
type shutdownMsg struct{}
type createProfileMsg struct {
name string
password string
}
type profileUnlockMsg struct {
password string
}
type connectMsg struct{}
type clearMsg struct{}
type profileInfoMsg struct{}
func isHelpCmd(cmds []string) bool {
if cmds[0] == "help" {
return true
}
return false
}
func isQuitCmd(cmds []string) bool {
if cmds[0] == "quit" {
return true
}
return false
}
func isProfileCreateCmd(cmds []string) bool {
if cmds[0] == "profile" && cmds[1] == "create" {
return true
}
return false
}
func isProfileUnlockCmd(cmds []string) bool {
if cmds[0] == "profile" && cmds[1] == "unlock" {
return true
}
return false
}
func isProfileInfoCmd(cmds []string) bool {
if cmds[0] == "profile" && cmds[1] == "info" {
return true
}
return false
}
func isConnectCmd(cmds []string) bool {
if cmds[0] == "connect" {
return true
}
return false
}
func isClearCmd(cmds []string) bool {
if cmds[0] == "clear" {
return true
}
return false
}
func handleCommand(cmd, hiddenInput string) tea.Msg {
cmds := strings.Split(cmd, " ")
switch {
case isHelpCmd(cmds):
return cmdMsg{output: cmdHelp}
case isQuitCmd(cmds):
return shutdownMsg{}
case isProfileCreateCmd(cmds):
if len(cmds) != 5 {
return cmdMsg{output: cmdHelp}
}
passwords := strings.Split(strings.TrimSpace(hiddenInput), " ")
if passwords[0] != passwords[1] {
return cmdMsg{output: "profile create: passwords do not match?"}
}
return createProfileMsg{
name: cmds[2],
password: passwords[1],
}
case isProfileUnlockCmd(cmds):
if len(cmds) != 3 {
return cmdMsg{output: cmdHelp}
}
return profileUnlockMsg{
password: strings.TrimSpace(hiddenInput),
}
case isProfileInfoCmd(cmds):
return profileInfoMsg{}
case isConnectCmd(cmds):
return connectMsg{}
case isClearCmd(cmds):
return clearMsg{}
default:
return cmdMsg{output: "unknown command"}
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width - 2
m.height = msg.Height - 2
m.viewport = viewport.New(
m.width,
m.height-3,
)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
if m.shutdown() {
return m, tea.Quit
}
case "enter":
val := m.input.Value()
if len(val) == 0 {
break
}
if string(val[0]) == "/" {
cmds = append(cmds, func() tea.Msg {
return handleCommand(val[1:], m.hiddenInput)
})
}
default:
val := m.input.Value()
if len(val) != 0 {
if string(val[0]) == "/" {
cmds := strings.Split(val[1:], " ")
if cmds[0] == "profile" && len(cmds) >= 2 {
if cmds[1] == "create" && len(cmds) > 3 || cmds[1] == "unlock" && len(cmds) > 2 {
lastChar := string(val[len(val)-1])
m.hiddenInput += lastChar
if lastChar != " " {
newCmd := []rune(val)
newCmd[len(val)-1] = '*'
m.input.SetValue(string(newCmd))
}
}
}
}
}
}
case cmdMsg:
m.input.Reset()
m.statusBuffer <- msg.output
case shutdownMsg:
m.input.Reset()
if m.shutdown() {
return m, tea.Quit
}
case createProfileMsg:
m.hiddenInput = ""
m.input.Reset()
m.app.CreateProfile(msg.name, msg.password, false)
m.statusBuffer <- fmt.Sprintf("created new profile: %s", msg.name)
profiles := m.app.ListProfiles() // TODO
m.profiles = append(m.profiles, profile{
name: msg.name,
onion: profiles[len(profiles)-1],
})
case profileUnlockMsg:
m.hiddenInput = ""
m.input.Reset()
switch m.connState {
case offline:
m.statusBuffer <- "offline, run /connect first?"
break
case connecting:
m.statusBuffer <- "still connecting, hold on a sec..."
break
}
m.app.LoadProfiles(msg.password)
var unlocked []profile
for _, onion := range m.app.ListProfiles() {
peer := m.app.GetPeer(onion)
name, _ := peer.GetScopedZonedAttribute(attr.PublicScope, attr.ProfileZone, constants.Name)
unlocked = append(unlocked, profile{name: name, onion: onion})
}
if len(unlocked) > 0 {
var names []string
for _, p := range unlocked {
names = append(names, p.name)
}
m.statusBuffer <- fmt.Sprintf("unlocked profile(s): %s", strings.Join(names, " "))
}
m.profiles = unlocked
case profileInfoMsg:
m.input.Reset()
if m.connState != connected {
m.statusBuffer <- "offline, run /connect first?"
break
}
if len(m.profiles) == 0 {
m.statusBuffer <- "no profiles loaded"
break
}
profile := m.profiles[m.profileState]
m.statusBuffer <- fmt.Sprintf("profile onion: %s", profile.onion)
case statusMsg:
existing := m.viewport.View()
m.viewport.SetContent(existing + "\n" + msg.msg)
m.viewport.GotoBottom()
cmds = append(cmds, m.handleStatusMessage())
case clearMsg:
m.input.Reset()
m.viewport.SetContent("")
m.viewport.GotoBottom()
case connectMsg:
m.input.Reset()
cmds = append(cmds, m.initApp())
case appInitialisedMsg:
m.app = msg.app
m.acn = msg.acn
m.connState = connected
m.statusBuffer <- "create or unlock a profile to get started"
m.statusBuffer <- "run /help to see the command listing"
case errMsg:
m.statusBuffer <- msg.Error()
}
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m model) View() string {
body := strings.Builder{}
channelBar := channelBarStyle.
Width(m.width).
AlignVertical(lipgloss.Center).
Render(lipgloss.NewStyle().Background(lipgloss.Color("14")).Foreground(lipgloss.Color("#fffff")).Render("N/A"))
statusButton := profileNameButtonStyle.
Width(m.width).
AlignVertical(lipgloss.Center).
Render(lipgloss.NewStyle().Background(lipgloss.Color("26")).Foreground(lipgloss.Color("#fffff")).Render("status"))
var profileNames []string
for _, profile := range m.profiles {
rendered := profileNameButtonStyle.Render(profile.name)
profileNames = append(profileNames, rendered)
}
buttonBar := []string{statusButton}
buttonBar = append(buttonBar, profileNames...)
body.WriteString(
mainStyle.
Width(m.width).
Height(m.height).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
viewportStyle.
Width(m.width).
Render(m.viewport.View()),
lipgloss.JoinHorizontal(
lipgloss.Left,
channelBar,
),
lipgloss.JoinHorizontal(
lipgloss.Left,
buttonBar...,
),
lipgloss.PlaceVertical(
1,
lipgloss.Bottom,
inputStyle.
Width(m.width).
Render(m.input.View()),
),
),
),
)
return body.String()
}