package ui import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "image" "io" "mime" "net/mail" "net/url" "sort" "strconv" "strings" "sync" "time" "unicode/utf8" "aegis/internal/config" "aegis/internal/cryptokit" "aegis/internal/filter" "aegis/internal/identity" "aegis/internal/nntp" vfaceprofile "aegis/internal/profile" "aegis/internal/smtpclient" "aegis/internal/store" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) const overviewLimit = int64(500) const messageIDDomain = "aegis.virebent.art" type application struct { window fyne.Window configPath string settings config.Settings state *store.Store filterRules []filter.Rule vfaceProfile *vfaceprofile.Profile vfaceVaultPath string clientMu sync.RWMutex client *nntp.Client status *widget.Label progress *widget.ProgressBarInfinite connect *widget.Button disconnect *widget.Button refresh *widget.Button groupSearch *widget.Entry groupList *widget.List groupDetail *widget.Label subscribe *widget.Button unsubscribe *widget.Button loadGroup *widget.Button groups []nntp.GroupInfo visibleGroups []nntp.GroupInfo selectedGroup string headerSearch *widget.Entry headerList *widget.List articleHeaders *widget.Label showAllHeaders *widget.Check body *widget.Entry articleText string headers []nntp.ArticleHeader visibleHeader []nntp.ArticleHeader loadedGroup string selectedHeader nntp.ArticleHeader hasSelectedHeader bool markRead *widget.Button bookmark *widget.Button reply *widget.Button composeGroups *widget.Entry composeDelivery *widget.Select composeMail2News *widget.Entry composeFrom *widget.Entry composeSubject *widget.Entry composeReferences *widget.Entry composeFollowupTo *widget.Entry composeBody *widget.Entry composeCryptoMode *widget.Select composeCryptoKey *widget.Entry composeCryptoSigningKey *widget.Entry composeCryptoKeyURL *widget.Entry cryptoAlgorithm *widget.Select cryptoOperation *widget.Select cryptoMessage *widget.Entry cryptoPrimary *widget.Entry cryptoSecret *widget.Entry cryptoSecondary *widget.Entry cryptoPrimaryLabel *widget.Label cryptoSecretLabel *widget.Label cryptoSecondaryLabel *widget.Label cryptoPrimaryBox *fyne.Container cryptoSecretBox *fyne.Container cryptoSecondaryBox *fyne.Container cryptoOutput *widget.Label hostEntry *widget.Entry portEntry *widget.Entry tlsCheck *widget.Check startTLSCheck *widget.Check tlsSkipVerify *widget.Check usernameEntry *widget.Entry passwordEntry *widget.Entry saslSelect *widget.Select compressionCheck *widget.Check proxySelect *widget.Select proxyEntry *widget.Entry smtpHostEntry *widget.Entry smtpPortEntry *widget.Entry smtpModeSelect *widget.Select smtpUserEntry *widget.Entry smtpEmailEntry *widget.Entry smtpRecipientEntry *widget.Entry smtpPasswordEntry *widget.Entry smtpSkipVerify *widget.Check displayEntry *widget.Entry emailEntry *widget.Entry filterField *widget.Select filterOperator *widget.Select filterPattern *widget.Entry filterAction *widget.Select filterTag *widget.Entry vfaceUsernameEntry *widget.Entry vfaceEmailEntry *widget.Entry vfacePasswordEntry *widget.Entry vfaceConfirmEntry *widget.Entry vfaceStatus *widget.Label vfaceImage *canvas.Image vfaceHash *widget.Label } func Run() { configPath, pathErr := config.DefaultPath() settings := config.Default() loadErr := pathErr if pathErr == nil { var err error settings, err = config.Load(configPath) if err != nil { loadErr = err settings = config.Default() } } fyneApp := app.NewWithID("art.virebent.aegis") window := fyneApp.NewWindow("Aegis Usenet Client") window.Resize(fyne.NewSize(1180, 800)) a := &application{window: window, configPath: configPath, settings: settings} if statePath, err := store.DefaultPath(); err == nil { if localState, stateErr := store.Open(statePath); stateErr == nil { a.state = localState a.filterRules = localState.Snapshot().Filters } else if loadErr == nil { loadErr = stateErr } } window.SetContent(a.build()) if loadErr != nil { dialog.ShowError(loadErr, window) } window.ShowAndRun() a.closeClient() } func (a *application) build() fyne.CanvasObject { a.status = widget.NewLabel("Offline. Configure the server, then connect.") a.progress = widget.NewProgressBarInfinite() a.progress.Hide() reader := a.buildReader() composer := a.buildComposer() settings := a.buildSettings() tabs := container.NewAppTabs( container.NewTabItemWithIcon("News Reader", theme.HomeIcon(), reader), container.NewTabItemWithIcon("Compose", theme.MailComposeIcon(), composer), container.NewTabItemWithIcon("Profilo e VFace", theme.AccountIcon(), a.buildProfile()), container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settings), ) return container.NewBorder(nil, container.NewVBox(a.progress, a.status), nil, nil, tabs) } func (a *application) buildReader() fyne.CanvasObject { a.groupSearch = widget.NewEntry() a.groupSearch.SetPlaceHolder("Filter available newsgroups...") a.groupSearch.OnChanged = func(string) { a.filterGroups() } a.groupDetail = widget.NewLabel("Select a group to see its estimated population before subscribing.") a.groupDetail.Wrapping = fyne.TextWrapWord a.groupList = widget.NewList( func() int { return len(a.visibleGroups) }, func() fyne.CanvasObject { return widget.NewLabel("Newsgroup") }, func(id widget.ListItemID, object fyne.CanvasObject) { group := a.visibleGroups[id] prefix := " " if a.isSubscribed(group.Name) { prefix = "✓ " } object.(*widget.Label).SetText(fmt.Sprintf("%s%s (≈ %s posts)", prefix, group.Name, formatCount(group.EstimatedPost))) }, ) a.groupList.OnSelected = func(id widget.ListItemID) { if id < 0 || id >= len(a.visibleGroups) { return } group := a.visibleGroups[id] a.selectedGroup = group.Name a.groupDetail.SetText(fmt.Sprintf( "%s\nEstimated posts: %s (article numbers %d-%d)\nPosting flag: %s\nThe estimate may include gaps on the server.", group.Name, formatCount(group.EstimatedPost), group.Low, group.High, group.Posting, )) } a.subscribe = widget.NewButtonWithIcon("Subscribe", theme.ContentAddIcon(), a.subscribeSelected) a.unsubscribe = widget.NewButtonWithIcon("Unsubscribe", theme.ContentRemoveIcon(), a.unsubscribeSelected) a.loadGroup = widget.NewButtonWithIcon("Load articles", theme.ViewRefreshIcon(), a.loadSelectedGroup) groupButtons := container.NewGridWithColumns(3, a.subscribe, a.unsubscribe, a.loadGroup) groupPane := container.NewBorder( container.NewVBox(widget.NewLabelWithStyle("Available groups", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), a.groupSearch), container.NewVBox(a.groupDetail, groupButtons), nil, nil, a.groupList, ) a.body = widget.NewMultiLineEntry() a.body.SetPlaceHolder("Select an article to download it from the server...") a.body.TextStyle = fyne.TextStyle{Monospace: true} a.body.Disable() a.articleHeaders = widget.NewLabel("No article selected.") a.articleHeaders.Selectable = true a.articleHeaders.TextStyle = fyne.TextStyle{Monospace: true} a.articleHeaders.Wrapping = fyne.TextWrapOff a.showAllHeaders = widget.NewCheck("Show all headers", func(bool) { a.refreshArticleHeaders() }) headerDisplay := container.NewBorder(a.showAllHeaders, nil, nil, nil, container.NewVScroll(a.articleHeaders)) a.headerSearch = widget.NewEntry() a.headerSearch.SetPlaceHolder("Search loaded subjects or authors...") a.headerSearch.OnChanged = func(string) { a.filterHeaders() } a.headerList = widget.NewList( func() int { return len(a.visibleHeader) }, func() fyne.CanvasObject { return container.NewHBox( widget.NewIcon(theme.DocumentIcon()), widget.NewLabel("Subject"), layout.NewSpacer(), widget.NewLabel("Author"), ) }, func(id widget.ListItemID, object fyne.CanvasObject) { header := a.visibleHeader[id] box := object.(*fyne.Container) prefix := "" if a.headerRead(header) { prefix = "✓ " } if a.headerBookmarked(header) { prefix += "★ " } box.Objects[1].(*widget.Label).SetText(prefix + header.Subject) box.Objects[3].(*widget.Label).SetText(header.From) }, ) a.headerList.OnSelected = func(id widget.ListItemID) { if id < 0 || id >= len(a.visibleHeader) { return } header := a.visibleHeader[id] a.selectedHeader = header a.hasSelectedHeader = true a.refreshArticleActions() a.loadArticle(a.loadedGroup, header) } headerPane := container.NewBorder(a.headerSearch, nil, nil, nil, a.headerList) bodyPane := container.NewVSplit(headerDisplay, a.body) bodyPane.SetOffset(0.24) rightSplit := container.NewVSplit(headerPane, bodyPane) rightSplit.SetOffset(0.45) mainSplit := container.NewHSplit(groupPane, rightSplit) mainSplit.SetOffset(0.36) a.connect = widget.NewButtonWithIcon("Connect", theme.LoginIcon(), a.connectServer) a.disconnect = widget.NewButtonWithIcon("Disconnect", theme.LogoutIcon(), a.disconnectServer) a.disconnect.Disable() a.refresh = widget.NewButtonWithIcon("Refresh groups", theme.ViewRefreshIcon(), a.refreshGroups) a.refresh.Disable() a.markRead = widget.NewButton("Mark read", a.toggleRead) a.bookmark = widget.NewButton("Bookmark", a.toggleBookmark) a.reply = widget.NewButton("Reply", a.replyToSelected) a.markRead.Disable() a.bookmark.Disable() a.reply.Disable() toolbar := container.NewHBox(a.connect, a.disconnect, a.refresh, layout.NewSpacer(), a.reply, a.markRead, a.bookmark) return container.NewBorder(toolbar, nil, nil, nil, mainSplit) } func (a *application) buildComposer() fyne.CanvasObject { a.composeGroups = widget.NewEntry() a.composeGroups.SetPlaceHolder("comp.lang.go,example.group") a.composeDelivery = widget.NewSelect([]string{"NNTP direct posting", "SMTP mail2news"}, nil) a.composeDelivery.SetSelected("NNTP direct posting") a.composeMail2News = widget.NewEntry() a.composeMail2News.SetText(a.settings.SMTPRecipient) a.composeFrom = widget.NewEntry() a.composeSubject = widget.NewEntry() a.composeReferences = widget.NewEntry() a.composeReferences.SetPlaceHolder("Filled automatically for replies") a.composeFollowupTo = widget.NewEntry() a.composeFollowupTo.SetPlaceHolder("Optional Followup-To newsgroup") a.composeBody = widget.NewMultiLineEntry() a.composeBody.SetPlaceHolder("Article body...") a.composeFrom.SetText(formatFrom(a.settings.DisplayName, a.settings.Email)) a.composeFrom.Disable() a.composeCryptoMode = widget.NewSelect([]string{ "Plain", "Sign with Ed25519", "Encrypt with age", "Encrypt with age and sign", }, nil) a.composeCryptoMode.SetSelected("Plain") a.composeCryptoKey = widget.NewMultiLineEntry() a.composeCryptoKey.SetPlaceHolder("age recipient (native X25519 or SSH Ed25519/RSA)") a.composeCryptoKey.Wrapping = fyne.TextWrapOff a.composeCryptoSigningKey = widget.NewMultiLineEntry() a.composeCryptoSigningKey.SetPlaceHolder("Ed25519 private key, base64 or hexadecimal") a.composeCryptoSigningKey.Wrapping = fyne.TextWrapOff a.composeCryptoKeyURL = widget.NewEntry() a.composeCryptoKeyURL.SetPlaceHolder("Optional HTTPS URL for the public key") post := widget.NewButtonWithIcon("Post article", theme.MailSendIcon(), a.postArticle) form := widget.NewForm( widget.NewFormItem("Newsgroups", a.composeGroups), widget.NewFormItem("Delivery", a.composeDelivery), widget.NewFormItem("mail2news recipient", a.composeMail2News), widget.NewFormItem("From", a.composeFrom), widget.NewFormItem("Subject", a.composeSubject), widget.NewFormItem("References", a.composeReferences), widget.NewFormItem("Followup-To", a.composeFollowupTo), widget.NewFormItem("Crypto mode", a.composeCryptoMode), widget.NewFormItem("Age recipient", a.composeCryptoKey), widget.NewFormItem("Ed25519 signing key", a.composeCryptoSigningKey), widget.NewFormItem("Public-key URL", a.composeCryptoKeyURL), ) return container.NewBorder(form, post, nil, nil, a.composeBody) } func (a *application) buildProfile() fyne.CanvasObject { a.vfaceUsernameEntry = widget.NewEntry() a.vfaceUsernameEntry.SetPlaceHolder("Pseudonymous username") a.vfaceEmailEntry = widget.NewEntry() a.vfaceEmailEntry.SetPlaceHolder("Pseudonymous email address") a.vfacePasswordEntry = widget.NewPasswordEntry() a.vfacePasswordEntry.SetPlaceHolder("Vault password, minimum 12 characters") a.vfaceConfirmEntry = widget.NewPasswordEntry() a.vfaceConfirmEntry.SetPlaceHolder("Repeat vault password") a.vfaceStatus = widget.NewLabel("No VFace identity loaded. VFace is optional.") a.vfaceStatus.Wrapping = fyne.TextWrapWord a.vfaceHash = widget.NewLabel("") a.vfaceHash.Wrapping = fyne.TextWrapWord a.vfaceImage = canvas.NewImageFromImage(image.NewRGBA(image.Rect(0, 0, 48, 48))) a.vfaceImage.FillMode = canvas.ImageFillContain a.vfaceImage.SetMinSize(fyne.NewSize(96, 96)) a.vfaceImage.Hide() path, err := vfaceprofile.DefaultPath() if err == nil { a.vfaceVaultPath = path } pathLabel := widget.NewLabel("Vault: " + a.vfaceVaultPath) pathLabel.Wrapping = fyne.TextWrapBreak create := widget.NewButton("Create or replace VFace identity", a.createVFaceProfile) load := widget.NewButton("Load VFace identity", a.loadVFaceProfile) lock := widget.NewButton("Lock identity", a.lockVFaceProfile) form := widget.NewForm( widget.NewFormItem("VFace username", a.vfaceUsernameEntry), widget.NewFormItem("VFace email", a.vfaceEmailEntry), widget.NewFormItem("Vault password", a.vfacePasswordEntry), widget.NewFormItem("Confirm password", a.vfaceConfirmEntry), ) provider := widget.NewLabel("Crypto providers: age for software encryption. OpenPGP is available only through a YubiKey integration and is not exposed as a standalone format.") provider.Wrapping = fyne.TextWrapWord return container.NewVScroll(container.NewVBox( widget.NewLabelWithStyle("Optional pseudonymous identity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), form, container.NewHBox(create, load, lock), pathLabel, a.vfaceStatus, a.vfaceImage, a.vfaceHash, provider, )) } func (a *application) createVFaceProfile() { password := a.vfacePasswordEntry.Text if password != a.vfaceConfirmEntry.Text { dialog.ShowError(errors.New("VFace passwords do not match"), a.window) return } value, err := vfaceprofile.Generate(a.vfaceUsernameEntry.Text, a.vfaceEmailEntry.Text) if err != nil { dialog.ShowError(err, a.window) return } if a.vfaceVaultPath == "" { dialog.ShowError(errors.New("VFace vault path is unavailable"), a.window) return } a.setBusy(true, "Creating encrypted VFace vault...") go func() { saveErr := vfaceprofile.Save(a.vfaceVaultPath, value, password) fyne.Do(func() { a.setBusy(false, "") if saveErr != nil { dialog.ShowError(saveErr, a.window) return } a.vfaceProfile = &value a.vfaceConfirmEntry.SetText("") a.renderVFaceProfile(value) a.updateComposeIdentity() a.vfaceStatus.SetText("VFace identity created and encrypted on disk.") }) }() } func (a *application) loadVFaceProfile() { if a.vfaceVaultPath == "" { dialog.ShowError(errors.New("VFace vault path is unavailable"), a.window) return } password := a.vfacePasswordEntry.Text a.setBusy(true, "Loading encrypted VFace identity...") go func() { value, err := vfaceprofile.Load(a.vfaceVaultPath, password) fyne.Do(func() { a.setBusy(false, "") if err != nil { dialog.ShowError(err, a.window) return } a.vfaceProfile = &value a.vfaceUsernameEntry.SetText(value.Username) a.vfaceEmailEntry.SetText(value.Email) a.renderVFaceProfile(value) a.updateComposeIdentity() a.vfaceStatus.SetText("VFace identity loaded from encrypted disk vault.") }) }() } func (a *application) lockVFaceProfile() { a.vfaceProfile = nil if a.vfaceImage != nil { a.vfaceImage.Hide() } if a.vfaceStatus != nil { a.vfaceStatus.SetText("VFace identity locked. VFace is optional.") } a.updateComposeIdentity() } func (a *application) renderVFaceProfile(value vfaceprofile.Profile) { profile, err := identity.GenerateVFace(value.Username, value.Email, value.PublicKey) if err != nil { a.vfaceStatus.SetText("VFace profile unavailable: " + err.Error()) return } preview, err := identity.DecodeFacePNG(profile.FaceBase64) if err == nil { a.vfaceImage.Image = preview a.vfaceImage.Show() a.vfaceImage.Refresh() } a.vfaceHash.SetText("Identity SHA-256: " + profile.IdentityHash + "\nPNG SHA-256: " + profile.PNGHash) } func (a *application) updateComposeIdentity() { if a.composeFrom == nil { return } if a.vfaceProfile != nil { a.composeFrom.SetText(formatFrom(a.vfaceProfile.Username, a.vfaceProfile.Email)) } else { a.composeFrom.SetText(formatFrom(a.settings.DisplayName, a.settings.Email)) } } func (a *application) buildCrypto() fyne.CanvasObject { a.cryptoAlgorithm = widget.NewSelect([]string{"OpenPGP", "age", "Ed25519", "YubiCrypt"}, nil) a.cryptoAlgorithm.SetSelected("OpenPGP") a.cryptoOperation = widget.NewSelect([]string{"Sign", "Verify", "Encrypt", "Decrypt"}, nil) a.cryptoOperation.SetSelected("Sign") a.cryptoMessage = widget.NewMultiLineEntry() a.cryptoMessage.SetPlaceHolder("Message or ciphertext") a.cryptoMessage.Wrapping = fyne.TextWrapOff a.cryptoPrimary = widget.NewMultiLineEntry() a.cryptoPrimary.SetPlaceHolder("Key material supplied by you") a.cryptoPrimary.Wrapping = fyne.TextWrapOff a.cryptoSecret = widget.NewPasswordEntry() a.cryptoSecret.SetPlaceHolder("YubiKey PIV PIN, session only") a.cryptoSecondary = widget.NewMultiLineEntry() a.cryptoSecondary.SetPlaceHolder("Optional second key or signature") a.cryptoSecondary.Wrapping = fyne.TextWrapOff a.cryptoPrimaryLabel = widget.NewLabel("Private key / identity") a.cryptoSecretLabel = widget.NewLabel("Secret") a.cryptoSecondaryLabel = widget.NewLabel("Public key / recipient") a.cryptoOutput = widget.NewLabel("No result yet.") a.cryptoOutput.Selectable = true a.cryptoOutput.Wrapping = fyne.TextWrapOff a.cryptoOutput.TextStyle = fyne.TextStyle{Monospace: true} a.cryptoAlgorithm.OnChanged = func(string) { a.refreshCryptoFields() } a.cryptoOperation.OnChanged = func(string) { a.refreshCryptoFields() } run := widget.NewButtonWithIcon("Run operation", theme.MediaPlayIcon(), a.runCryptoOperation) clear := widget.NewButtonWithIcon("Clear", theme.DeleteIcon(), func() { a.cryptoMessage.SetText("") a.cryptoPrimary.SetText("") a.cryptoSecret.SetText("") a.cryptoSecondary.SetText("") a.cryptoOutput.SetText("No result yet.") }) note := widget.NewLabel("Aegis does not generate or save keys. Key fields are used only for this session. YubiCrypt requires the optional yubicrypt executable, a YubiKey, pcscd and the PIV PIN.") note.Wrapping = fyne.TextWrapWord form := widget.NewForm( widget.NewFormItem("Format", a.cryptoAlgorithm), widget.NewFormItem("Operation", a.cryptoOperation), ) messageBox := container.NewVBox(widget.NewLabel("Message / ciphertext"), a.cryptoMessage) a.cryptoPrimaryBox = container.NewVBox(a.cryptoPrimaryLabel, a.cryptoPrimary) a.cryptoSecretBox = container.NewVBox(a.cryptoSecretLabel, a.cryptoSecret) a.cryptoSecondaryBox = container.NewVBox(a.cryptoSecondaryLabel, a.cryptoSecondary) a.refreshCryptoFields() keys := container.NewVBox(a.cryptoPrimaryBox, a.cryptoSecretBox, a.cryptoSecondaryBox) input := container.NewVSplit(messageBox, keys) input.SetOffset(0.42) result := container.NewBorder(widget.NewLabel("Result, selectable for copy"), nil, nil, nil, container.NewVScroll(a.cryptoOutput)) main := container.NewVSplit(container.NewVSplit(form, input), result) main.SetOffset(0.34) return container.NewBorder(nil, container.NewVBox(note, container.NewHBox(run, clear)), nil, nil, main) } func (a *application) refreshCryptoFields() { if a.cryptoAlgorithm == nil || a.cryptoOperation == nil || a.cryptoPrimaryBox == nil || a.cryptoSecretBox == nil || a.cryptoSecondaryBox == nil { return } algorithm := a.cryptoAlgorithm.Selected operation := a.cryptoOperation.Selected a.cryptoPrimaryBox.Show() a.cryptoSecretBox.Hide() a.cryptoSecondaryBox.Show() switch operation { case "Verify": if algorithm == "YubiCrypt" { a.cryptoPrimaryBox.Hide() a.cryptoSecondaryBox.Hide() } else { a.cryptoPrimaryLabel.SetText("Signature") a.cryptoSecondaryLabel.SetText("Public key") } case "Encrypt": if algorithm == "OpenPGP" { a.cryptoPrimaryLabel.SetText("Recipient public key") a.cryptoSecondaryLabel.SetText("Optional signer private key") } else if algorithm == "YubiCrypt" { a.cryptoPrimaryLabel.SetText("RSA recipient certificate/key (PEM)") a.cryptoSecondaryBox.Hide() } else { a.cryptoPrimaryLabel.SetText("Recipient key") a.cryptoSecondaryLabel.SetText("Not used") } case "Decrypt": if algorithm == "YubiCrypt" { a.cryptoPrimaryBox.Hide() a.cryptoSecretBox.Show() a.cryptoSecretLabel.SetText("YubiKey PIV PIN") a.cryptoSecondaryBox.Hide() } else { a.cryptoPrimaryLabel.SetText("Private key / identity") a.cryptoSecondaryLabel.SetText("Not used") } default: if algorithm == "YubiCrypt" { a.cryptoPrimaryBox.Hide() a.cryptoSecretBox.Show() a.cryptoSecretLabel.SetText("YubiKey PIV PIN") a.cryptoSecondaryBox.Hide() } else { a.cryptoPrimaryLabel.SetText("Private key") a.cryptoSecondaryLabel.SetText("Not used") } } a.cryptoSecondary.Disable() if operation == "Verify" || (operation == "Encrypt" && algorithm == "OpenPGP") { a.cryptoSecondary.Enable() } a.cryptoPrimaryBox.Refresh() a.cryptoSecretBox.Refresh() a.cryptoSecondaryBox.Refresh() } func (a *application) runCryptoOperation() { algorithm := a.cryptoAlgorithm.Selected operation := a.cryptoOperation.Selected message := []byte(a.cryptoMessage.Text) primary := a.cryptoPrimary.Text secret := a.cryptoSecret.Text secondary := a.cryptoSecondary.Text if len(strings.TrimSpace(string(message))) == 0 { dialog.ShowError(errors.New("message or ciphertext is required"), a.window) return } if algorithm == "YubiCrypt" && operation != "Verify" && operation != "Encrypt" && strings.TrimSpace(secret) == "" { dialog.ShowError(errors.New("YubiKey PIV PIN is required"), a.window) return } if algorithm == "YubiCrypt" && operation == "Encrypt" && strings.TrimSpace(primary) == "" { dialog.ShowError(errors.New("RSA recipient certificate/key is required"), a.window) return } if algorithm != "YubiCrypt" && strings.TrimSpace(primary) == "" { dialog.ShowError(errors.New("primary key material is required"), a.window) return } a.setBusy(true, "Running "+algorithm+" "+operation+"...") go func() { var result string var err error switch algorithm { case "OpenPGP": switch operation { case "Sign": result, err = cryptokit.SignOpenPGPDetached(message, primary) case "Verify": err = cryptokit.VerifyOpenPGPDetached(message, primary, secondary) result = "OpenPGP signature verified." case "Encrypt": result, err = cryptokit.EncryptOpenPGP(message, primary, secondary) case "Decrypt": var plaintext []byte plaintext, err = cryptokit.DecryptOpenPGP(message, primary) result = string(plaintext) } case "age": switch operation { case "Encrypt": var ciphertext []byte ciphertext, err = cryptokit.EncryptAge(message, primary) result = string(ciphertext) case "Decrypt": var plaintext []byte plaintext, err = cryptokit.DecryptAge(message, primary) result = string(plaintext) default: err = errors.New("age supports Encrypt and Decrypt") } case "Ed25519": switch operation { case "Sign": result, err = cryptokit.SignEd25519(message, primary) case "Verify": err = cryptokit.VerifyEd25519(message, primary, secondary) result = "Ed25519 signature verified." default: err = errors.New("raw Ed25519 supports Sign and Verify; use age SSH keys for encryption") } case "YubiCrypt": switch operation { case "Sign": var signed []byte signed, err = cryptokit.SignYubiCrypt(message, secret) result = string(signed) case "Verify": var verified []byte verified, err = cryptokit.VerifyYubiCrypt(message) result = string(verified) case "Encrypt": var ciphertext []byte ciphertext, err = cryptokit.EncryptYubiCrypt(message, primary) result = string(ciphertext) case "Decrypt": var plaintext []byte plaintext, err = cryptokit.DecryptYubiCrypt(message, secret) result = string(plaintext) } } fyne.Do(func() { a.setBusy(false, "Cryptography operation completed.") if err != nil { dialog.ShowError(err, a.window) return } a.cryptoOutput.SetText(result) }) }() } func (a *application) buildSettings() fyne.CanvasObject { a.hostEntry = widget.NewEntry() a.hostEntry.SetText(a.settings.Host) a.portEntry = widget.NewEntry() a.portEntry.SetText(a.settings.Port) a.tlsCheck = widget.NewCheck("Use TLS", nil) a.tlsCheck.SetChecked(a.settings.UseTLS) a.startTLSCheck = widget.NewCheck("Use STARTTLS", nil) a.startTLSCheck.SetChecked(a.settings.StartTLS) a.tlsSkipVerify = widget.NewCheck("Do not verify the TLS certificate (unsafe, explicit opt-in)", nil) a.tlsSkipVerify.SetChecked(a.settings.SkipTLSVerify) a.usernameEntry = widget.NewEntry() a.usernameEntry.SetText(a.settings.Username) a.passwordEntry = widget.NewPasswordEntry() a.passwordEntry.SetPlaceHolder("Session only, never saved") a.saslSelect = widget.NewSelect([]string{"None", "PLAIN"}, nil) if a.settings.SASLMechanism == "PLAIN" { a.saslSelect.SetSelected("PLAIN") } else { a.saslSelect.SetSelected("None") } a.compressionCheck = widget.NewCheck("Use COMPRESS DEFLATE when advertised", nil) a.compressionCheck.SetChecked(a.settings.UseCompression) a.proxySelect = widget.NewSelect([]string{"DIRECT", "SOCKS5"}, nil) a.proxySelect.SetSelected(a.settings.ProxyType) a.proxyEntry = widget.NewEntry() a.proxyEntry.SetText(a.settings.ProxyAddress) a.smtpHostEntry = widget.NewEntry() a.smtpHostEntry.SetText(a.settings.SMTPHost) a.smtpPortEntry = widget.NewEntry() a.smtpPortEntry.SetText(a.settings.SMTPPort) a.smtpModeSelect = widget.NewSelect([]string{"Cleartext (no TLS)", "TLS", "STARTTLS"}, nil) if a.settings.SMTPMode == "TLS" || a.settings.SMTPMode == "STARTTLS" { a.smtpModeSelect.SetSelected(a.settings.SMTPMode) } else { a.smtpModeSelect.SetSelected("Cleartext (no TLS)") } a.smtpUserEntry = widget.NewEntry() a.smtpUserEntry.SetText(a.settings.SMTPUsername) a.smtpEmailEntry = widget.NewEntry() a.smtpEmailEntry.SetText(a.settings.SMTPEmail) a.smtpRecipientEntry = widget.NewEntry() a.smtpRecipientEntry.SetText(a.settings.SMTPRecipient) a.smtpPasswordEntry = widget.NewPasswordEntry() a.smtpPasswordEntry.SetPlaceHolder("Session only, never saved") a.smtpSkipVerify = widget.NewCheck("Do not verify SMTP TLS certificate (unsafe)", nil) a.smtpSkipVerify.SetChecked(a.settings.SMTPSkipVerify) a.displayEntry = widget.NewEntry() a.displayEntry.SetText(a.settings.DisplayName) a.emailEntry = widget.NewEntry() a.emailEntry.SetText(a.settings.Email) a.filterField = widget.NewSelect([]string{"subject", "from", "newsgroups", "message-id", "references", "date", "body", "header", "vface-hash", "read", "any"}, nil) a.filterField.SetSelected("subject") a.filterOperator = widget.NewSelect([]string{"contains", "exact", "regexp", "glob"}, nil) a.filterOperator.SetSelected("contains") a.filterPattern = widget.NewEntry() a.filterPattern.SetPlaceHolder("es. [spam], example.org, ") a.filterAction = widget.NewSelect([]string{"hide", "mark-read", "highlight", "tag", "mute-thread", "keep"}, nil) a.filterAction.SetSelected("hide") a.filterTag = widget.NewEntry() a.filterTag.SetPlaceHolder("Tag, se l'azione è tag") save := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), a.saveSettings) form := widget.NewForm( widget.NewFormItem("NNTP host", a.hostEntry), widget.NewFormItem("Port", a.portEntry), widget.NewFormItem("Transport", container.NewVBox(a.tlsCheck, a.startTLSCheck, a.tlsSkipVerify, a.compressionCheck)), widget.NewFormItem("NNTP username", a.usernameEntry), widget.NewFormItem("NNTP password", a.passwordEntry), widget.NewFormItem("SASL", a.saslSelect), widget.NewFormItem("Proxy", a.proxySelect), widget.NewFormItem("SOCKS5 address", a.proxyEntry), widget.NewFormItem("SMTP mail2news host", a.smtpHostEntry), widget.NewFormItem("SMTP port", a.smtpPortEntry), widget.NewFormItem("SMTP transport", a.smtpModeSelect), widget.NewFormItem("SMTP username", a.smtpUserEntry), widget.NewFormItem("SMTP email", a.smtpEmailEntry), widget.NewFormItem("mail2news recipient", a.smtpRecipientEntry), widget.NewFormItem("SMTP password", a.smtpPasswordEntry), widget.NewFormItem("SMTP TLS", a.smtpSkipVerify), widget.NewFormItem("NNTP display name", a.displayEntry), widget.NewFormItem("NNTP email", a.emailEntry), ) filters := widget.NewButton("Filters", a.showFilterEditor) content := container.NewVBox(form, filters, save) return container.NewVScroll(content) } func (a *application) readSettingsForm() config.Settings { settings := a.settings settings.Host = strings.TrimSpace(a.hostEntry.Text) settings.Port = strings.TrimSpace(a.portEntry.Text) settings.UseTLS = a.tlsCheck.Checked settings.StartTLS = a.startTLSCheck.Checked settings.SkipTLSVerify = a.tlsSkipVerify.Checked settings.Username = strings.TrimSpace(a.usernameEntry.Text) if a.saslSelect.Selected == "PLAIN" { settings.SASLMechanism = "PLAIN" } else { settings.SASLMechanism = "" } settings.UseCompression = a.compressionCheck.Checked settings.ProxyType = a.proxySelect.Selected settings.ProxyAddress = strings.TrimSpace(a.proxyEntry.Text) settings.SMTPHost = strings.TrimSpace(a.smtpHostEntry.Text) settings.SMTPPort = strings.TrimSpace(a.smtpPortEntry.Text) if a.smtpModeSelect.Selected == "TLS" || a.smtpModeSelect.Selected == "STARTTLS" { settings.SMTPMode = a.smtpModeSelect.Selected } else { settings.SMTPMode = "" } settings.SMTPUsername = strings.TrimSpace(a.smtpUserEntry.Text) settings.SMTPEmail = strings.TrimSpace(a.smtpEmailEntry.Text) settings.SMTPRecipient = strings.TrimSpace(a.smtpRecipientEntry.Text) settings.SMTPSkipVerify = a.smtpSkipVerify.Checked settings.DisplayName = strings.TrimSpace(a.displayEntry.Text) settings.Email = strings.TrimSpace(a.emailEntry.Text) return settings } func (a *application) saveSettings() { settings := a.readSettingsForm() if err := settings.Validate(); err != nil { dialog.ShowError(err, a.window) return } if a.configPath == "" { dialog.ShowError(errors.New("configuration path is unavailable"), a.window) return } if err := config.Save(a.configPath, settings); err != nil { dialog.ShowError(err, a.window) return } a.settings = settings a.updateComposeIdentity() a.status.SetText("Settings saved. Password retained only for this session.") } func (a *application) addFilterRule() { if a.state == nil { dialog.ShowError(errors.New("local state is unavailable"), a.window) return } rule := filter.Rule{ ID: fmt.Sprintf("rule-%d", time.Now().UnixNano()), Enabled: true, Field: filter.Field(a.filterField.Selected), Operator: filter.Operator(a.filterOperator.Selected), Pattern: strings.TrimSpace(a.filterPattern.Text), Action: filter.Action(a.filterAction.Selected), Tag: strings.TrimSpace(a.filterTag.Text), } if _, err := filter.Evaluate(filter.Article{Headers: map[string]string{}}, []filter.Rule{rule}); err != nil { dialog.ShowError(err, a.window) return } a.filterRules = append(a.filterRules, rule) a.state.SetFilters(a.filterRules) if err := a.state.Save(); err != nil { dialog.ShowError(err, a.window) return } a.filterPattern.SetText("") a.filterTag.SetText("") a.filterHeaders() a.status.SetText("Filtro locale aggiunto.") } func (a *application) showFilterEditor() { form := widget.NewForm( widget.NewFormItem("Field", a.filterField), widget.NewFormItem("Operator", a.filterOperator), widget.NewFormItem("Pattern", a.filterPattern), widget.NewFormItem("Action", a.filterAction), widget.NewFormItem("Tag", a.filterTag), ) note := widget.NewLabel("Local filter only. It never deletes or rewrites server articles.") note.Wrapping = fyne.TextWrapWord content := container.NewVBox(note, form) dialog.ShowCustomConfirm("Filters", "Add filter", "Close", content, func(confirmed bool) { if confirmed { a.addFilterRule() } }, a.window) } func (a *application) connectServer() { settings := a.readSettingsForm() if err := settings.Validate(); err != nil { dialog.ShowError(err, a.window) return } password := a.passwordEntry.Text a.settings = settings a.setBusy(true, "Connecting securely to "+settings.Host+"...") go func() { ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second) defer cancel() client, err := nntp.Dial(ctx, nntp.DialConfig{ Host: settings.Host, Port: settings.Port, UseTLS: settings.UseTLS, StartTLS: settings.StartTLS, InsecureSkipVerify: settings.SkipTLSVerify, Username: settings.Username, Password: password, SASLMechanism: settings.SASLMechanism, UseCompression: settings.UseCompression, ProxyType: settings.ProxyType, ProxyAddress: settings.ProxyAddress, }) if err != nil { a.asyncError("Connection failed", err) return } groups, err := client.ListActive() if err != nil { client.Close() a.asyncError("Connected, but LIST ACTIVE failed", err) return } a.replaceClient(client) fyne.Do(func() { a.groups = groups a.filterGroups() a.connect.Disable() a.disconnect.Enable() a.refresh.Enable() a.setBusy(false, fmt.Sprintf("Connected. %s available newsgroups loaded.", formatCount(int64(len(groups))))) }) }() } func (a *application) disconnectServer() { a.closeClient() a.groups = nil a.visibleGroups = nil a.headers = nil a.visibleHeader = nil a.articleText = "" a.selectedGroup = "" a.loadedGroup = "" a.groupList.Refresh() a.headerList.Refresh() a.body.SetText("") a.articleHeaders.SetText("No article selected.") a.connect.Enable() a.disconnect.Disable() a.refresh.Disable() a.status.SetText("Disconnected.") } func (a *application) refreshGroups() { client := a.currentClient() if client == nil { dialog.ShowError(errors.New("connect to a server first"), a.window) return } a.setBusy(true, "Refreshing available newsgroups...") go func() { groups, err := client.ListActive() if err != nil { a.asyncError("Cannot refresh groups", err) return } fyne.Do(func() { a.groups = groups a.filterGroups() a.setBusy(false, fmt.Sprintf("%s available newsgroups loaded.", formatCount(int64(len(groups))))) }) }() } func (a *application) subscribeSelected() { if a.selectedGroup == "" { dialog.ShowError(errors.New("select a newsgroup first"), a.window) return } if !a.isSubscribed(a.selectedGroup) { a.settings.Subscriptions = append(a.settings.Subscriptions, a.selectedGroup) sort.Strings(a.settings.Subscriptions) if err := a.persistSubscriptions(); err != nil { dialog.ShowError(err, a.window) return } } a.composeGroups.SetText(a.selectedGroup) a.groupList.Refresh() a.status.SetText("Subscribed to " + a.selectedGroup + ".") } func (a *application) unsubscribeSelected() { if a.selectedGroup == "" { dialog.ShowError(errors.New("select a newsgroup first"), a.window) return } groups := a.settings.Subscriptions[:0] for _, group := range a.settings.Subscriptions { if group != a.selectedGroup { groups = append(groups, group) } } a.settings.Subscriptions = groups if err := a.persistSubscriptions(); err != nil { dialog.ShowError(err, a.window) return } a.groupList.Refresh() a.status.SetText("Unsubscribed from " + a.selectedGroup + ".") } func (a *application) persistSubscriptions() error { if a.configPath == "" { return errors.New("configuration path is unavailable") } settings := a.readSettingsForm() settings.Subscriptions = append([]string(nil), a.settings.Subscriptions...) if err := config.Save(a.configPath, settings); err != nil { return err } a.settings = settings return nil } func (a *application) loadSelectedGroup() { group := a.selectedGroup if group == "" { dialog.ShowError(errors.New("select a newsgroup first"), a.window) return } if !a.isSubscribed(group) { dialog.ShowError(errors.New("subscribe to the newsgroup before loading its articles"), a.window) return } client := a.currentClient() if client == nil { dialog.ShowError(errors.New("connect to a server first"), a.window) return } a.setBusy(true, "Loading recent headers from "+group+"...") go func() { status, headers, err := client.LatestOverview(group, overviewLimit) if err != nil { a.asyncError("Cannot load article overview", err) return } fyne.Do(func() { a.loadedGroup = group a.headers = headers if a.state != nil { for _, header := range headers { _ = a.state.UpsertArticle(store.Article{Key: articleKey(group, header), Group: group, Number: header.Number, Subject: header.Subject, From: header.From, MessageID: header.MessageID}) } _ = a.state.Save() } a.filterHeaders() a.body.SetText("") a.composeGroups.SetText(group) a.setBusy(false, fmt.Sprintf("%s: loaded %d recent headers, server reports %d articles.", group, len(headers), status.Count)) }) }() } func (a *application) loadArticle(group string, header nntp.ArticleHeader) { client := a.currentClient() if client == nil || group == "" { return } a.setBusy(true, fmt.Sprintf("Downloading article %d from %s...", header.Number, group)) go func() { article, err := client.ArticleInGroup(group, header.Number) if err != nil { a.asyncError("Cannot download article", err) return } fyne.Do(func() { a.body.SetText(article) a.articleText = article if a.state != nil { key := articleKey(group, header) _ = a.state.UpsertArticle(store.Article{Key: key, Group: group, Number: header.Number, Subject: header.Subject, From: header.From, MessageID: header.MessageID, Raw: article}) _ = a.state.SetRead(key, true) _ = a.state.Save() } a.refreshArticleHeaders() a.refreshArticleActions() a.headerList.Refresh() a.setBusy(false, fmt.Sprintf("Article %d downloaded from %s.", header.Number, group)) }) }() } func (a *application) replyToSelected() { if !a.hasSelectedHeader || strings.TrimSpace(a.articleText) == "" { return } message, err := mail.ReadMessage(strings.NewReader(a.articleText)) if err != nil { dialog.ShowError(fmt.Errorf("cannot parse selected article: %w", err), a.window) return } body, err := io.ReadAll(io.LimitReader(message.Body, 2<<20)) if err != nil { dialog.ShowError(fmt.Errorf("cannot read selected article: %w", err), a.window) return } groups := strings.TrimSpace(message.Header.Get("Followup-To")) if groups == "" || strings.EqualFold(groups, "poster") { groups = strings.TrimSpace(message.Header.Get("Newsgroups")) } a.composeGroups.SetText(groups) subject := strings.TrimSpace(message.Header.Get("Subject")) if !strings.HasPrefix(strings.ToLower(subject), "re:") { subject = "Re: " + subject } a.composeSubject.SetText(subject) references := strings.TrimSpace(message.Header.Get("References")) messageID := strings.TrimSpace(message.Header.Get("Message-ID")) if messageID != "" { if references != "" { references += " " } references += messageID } a.composeReferences.SetText(references) a.composeFollowupTo.SetText(strings.TrimSpace(message.Header.Get("Followup-To"))) a.composeBody.SetText(quoteBody(string(body), message.Header.Get("From"))) a.status.SetText("Reply preparata con quoting e References. Controlla Followup-To prima dell'invio.") } func quoteBody(body, from string) string { body = normalizeCRLF(body) lines := strings.Split(strings.TrimSuffix(body, "\r\n"), "\r\n") var builder strings.Builder if strings.TrimSpace(from) != "" { builder.WriteString("On behalf of ") builder.WriteString(strings.TrimSpace(from)) builder.WriteString(" wrote:\r\n") } for _, line := range lines { builder.WriteString("> ") builder.WriteString(line) builder.WriteString("\r\n") } return builder.String() } func (a *application) refreshArticleHeaders() { if a.articleHeaders == nil { return } text := formatArticleHeaders(a.articleText, a.showAllHeaders != nil && a.showAllHeaders.Checked) verification := identity.VerifyArticle(a.articleText) if verification.SignaturePresent || verification.PublicKey != "" { status := "VFace invalid" if verification.VFaceValid { status = "VFace valid" } if verification.SignaturePresent { if verification.SignatureValid { status += "; Ed25519 signature valid" } else { status += "; Ed25519 signature invalid" } } text += "\n\nVerification: " + status if verification.Error != nil { text += " (" + verification.Error.Error() + ")" } } a.articleHeaders.SetText(text) } func formatArticleHeaders(article string, showAll bool) string { if strings.TrimSpace(article) == "" { return "No article selected." } if showAll { raw := articleHeaderBlock(article) if raw != "" { return raw } } message, err := mail.ReadMessage(strings.NewReader(article)) if err != nil { raw := articleHeaderBlock(article) if raw == "" { return "The article headers could not be parsed." } return raw } important := []string{ "From", "Date", "Newsgroups", "Subject", "Message-ID", "References", "Followup-To", "Reply-To", "Organization", "User-Agent", "MIME-Version", "Content-Type", "Content-Transfer-Encoding", "Face", "OpenPGP", "X-OpenPGP", "X-Signature", "X-Aegis-Crypto-Version", "X-Aegis-Encryption", "X-Aegis-Signature", "X-Aegis-Public-Key", "X-Aegis-Key-Fingerprint", "X-Aegis-Public-Key-URL", "X-VFace-Version", "X-Ed25519-Pub", "X-Ed25519-Sig", "Identity-Hash", "X-VFace-Hash", "X-VFace-PNG-SHA256", "X-VFace-Verify", } var lines []string for _, name := range important { if value := strings.TrimSpace(message.Header.Get(name)); value != "" { lines = append(lines, name+": "+value) } } if len(lines) == 0 { return "No recognized headers in this article." } return strings.Join(lines, "\n") } func articleHeaderBlock(article string) string { article = strings.ReplaceAll(article, "\r\n", "\n") article = strings.ReplaceAll(article, "\r", "\n") if separator := strings.Index(article, "\n\n"); separator >= 0 { return strings.TrimSpace(article[:separator]) } return "" } func (a *application) postArticle() { delivery := a.composeDelivery.Selected settings := a.readSettingsForm() if settings.SMTPHost == "" { delivery = "NNTP direct posting" } client := a.currentClient() if delivery != "SMTP mail2news" && client == nil { dialog.ShowError(errors.New("connect to a server first"), a.window) return } if err := settings.Validate(); err != nil { dialog.ShowError(err, a.window) return } groups, err := normalizeGroups(a.composeGroups.Text) if err != nil { dialog.ShowError(err, a.window) return } from := formatFrom(settings.DisplayName, settings.Email) identityHeaders := []string(nil) expectedPublicKey := "" signingKey := strings.TrimSpace(a.composeCryptoSigningKey.Text) if a.vfaceProfile != nil { profile, profileErr := identity.GenerateVFace(a.vfaceProfile.Username, a.vfaceProfile.Email, a.vfaceProfile.PublicKey) if profileErr != nil { dialog.ShowError(fmt.Errorf("invalid loaded VFace identity: %w", profileErr), a.window) return } from = formatFrom(a.vfaceProfile.Username, a.vfaceProfile.Email) identityHeaders = profile.Headers() expectedPublicKey = profile.PublicKey if signingKey == "" { signingKey = a.vfaceProfile.PrivateKey } } subject := strings.TrimSpace(a.composeSubject.Text) if from == "" || subject == "" || strings.ContainsAny(from+subject, "\r\n") { dialog.ShowError(errors.New("From and Subject are required and must be one line"), a.window) return } if _, err := mail.ParseAddress(from); err != nil { dialog.ShowError(fmt.Errorf("invalid From address: %w", err), a.window) return } var recipients []string if delivery == "SMTP mail2news" { recipient := strings.TrimSpace(a.composeMail2News.Text) if recipient == "" { recipient = strings.TrimSpace(settings.SMTPRecipient) } if recipient == "" { dialog.ShowError(errors.New("mail2news recipient is required for SMTP delivery"), a.window) return } if _, err := mail.ParseAddress(recipient); err != nil { dialog.ShowError(fmt.Errorf("invalid mail2news recipient: %w", err), a.window) return } recipients = []string{recipient} } article, err := buildArticleWithIdentityHeaders(groups, from, subject, a.composeBody.Text, identityHeaders, articleCryptoOptions{ Mode: a.composeCryptoMode.Selected, EncryptionKey: a.composeCryptoKey.Text, SigningKey: signingKey, PublicKeyURL: a.composeCryptoKeyURL.Text, ExpectedPublicKey: expectedPublicKey, References: strings.TrimSpace(a.composeReferences.Text), FollowupTo: strings.TrimSpace(a.composeFollowupTo.Text), }) if err != nil { dialog.ShowError(err, a.window) return } a.setBusy(true, "Posting article...") go func() { var postErr error if delivery == "SMTP mail2news" { smtpFrom := strings.TrimSpace(settings.SMTPEmail) if smtpFrom == "" { smtpFrom = from } postErr = smtpclient.Send(smtpclient.Config{ Host: settings.SMTPHost, Port: settings.SMTPPort, Mode: settings.SMTPMode, Username: settings.SMTPUsername, Password: a.smtpPasswordEntry.Text, InsecureSkipVerify: settings.SMTPSkipVerify, ProxyType: settings.ProxyType, ProxyAddress: settings.ProxyAddress, }, smtpFrom, recipients, []byte(article)) } else { postErr = client.Post(article) } if postErr != nil { a.asyncError("Posting failed", postErr) return } fyne.Do(func() { a.composeSubject.SetText("") a.composeBody.SetText("") a.composeReferences.SetText("") a.composeFollowupTo.SetText("") a.composeMail2News.SetText(settings.SMTPRecipient) a.composeCryptoKey.SetText("") a.composeCryptoSigningKey.SetText("") a.composeCryptoKeyURL.SetText("") a.composeCryptoMode.SetSelected("Plain") a.setBusy(false, "Article accepted by the NNTP server.") dialog.ShowInformation("Article posted", "The NNTP server accepted the article.", a.window) }) }() } func buildTextArticle(groups []string, from, subject, body string) (string, error) { return buildTextArticleWithCrypto(groups, from, subject, body, articleCryptoOptions{Mode: "Plain"}) } type articleCryptoOptions struct { Mode string EncryptionKey string SigningKey string PublicKeyURL string ExpectedPublicKey string References string FollowupTo string } func buildTextArticleWithCrypto(groups []string, from, subject, body string, options articleCryptoOptions) (string, error) { face, err := identity.GenerateFace(from) if err != nil { return "", fmt.Errorf("generate Face header: %w", err) } return buildArticleWithIdentityHeaders(groups, from, subject, body, []string{identity.FormatFaceHeader(face)}, options) } func buildTextArticleWithVFace(groups []string, from, subject, body string, profile identity.VFace, options articleCryptoOptions) (string, error) { if profile.IdentityHash == "" { return buildArticleWithIdentityHeaders(groups, from, subject, body, nil, options) } return buildArticleWithIdentityHeaders(groups, from, subject, body, profile.Headers(), options) } func buildArticleWithIdentityHeaders(groups []string, from, subject, body string, identityHeaders []string, options articleCryptoOptions) (string, error) { fromHeader, err := formatFromHeader(from) if err != nil { return "", err } if subject == "" || strings.ContainsAny(subject, "\r\n") { return "", errors.New("Subject is required and must be one line") } if !utf8.ValidString(body) { return "", errors.New("article body is not valid UTF-8") } messageID, err := generateMessageID() if err != nil { return "", err } body = strings.ReplaceAll(body, "\r\n", "\n") body = strings.ReplaceAll(body, "\r", "\n") body = strings.ReplaceAll(body, "\n", "\r\n") mode := strings.TrimSpace(options.Mode) if mode == "" { mode = "Plain" } cryptoHeaders, contentType, contentTransferEncoding, wireBody, err := prepareArticleCrypto(body, mode, options) if err != nil { return "", err } headers := []string{ "From: " + fromHeader, "Message-ID: " + messageID, "Newsgroups: " + strings.Join(groups, ","), "Subject: " + mime.QEncoding.Encode("UTF-8", subject), "Date: " + time.Now().Format(time.RFC1123Z), "User-Agent: Aegis/0.1", "MIME-Version: 1.0", "Content-Type: " + contentType, "Content-Transfer-Encoding: " + contentTransferEncoding, } if options.References != "" { if strings.ContainsAny(options.References, "\r\n") { return "", errors.New("References must not contain line breaks") } headers = append(headers, foldHeader("References", options.References)) } if options.FollowupTo != "" { if !validFollowupTo(options.FollowupTo) { return "", errors.New("Followup-To must be poster or valid newsgroup names") } headers = append(headers, foldHeader("Followup-To", options.FollowupTo)) } headers = append(headers, identityHeaders...) headers = append(headers, cryptoHeaders...) article := strings.Join([]string{ strings.Join(headers, "\r\n"), "", wireBody, }, "\r\n") if err := nntp.ValidateArticle(article); err != nil { return "", fmt.Errorf("article format is not Usenet-safe: %w", err) } return article, nil } func validFollowupTo(value string) bool { if strings.EqualFold(strings.TrimSpace(value), "poster") { return true } _, err := normalizeGroups(value) return err == nil } func generateMessageID() (string, error) { randomPart := make([]byte, 16) if _, err := rand.Read(randomPart); err != nil { return "", fmt.Errorf("generate Message-ID: %w", err) } return "<" + hex.EncodeToString(randomPart) + "@" + messageIDDomain + ">", nil } func prepareArticleCrypto(body, mode string, options articleCryptoOptions) (headers []string, contentType, transferEncoding, wireBody string, err error) { wireBody = body contentType = "text/plain; charset=UTF-8" transferEncoding = "8bit" signingKey := strings.TrimSpace(options.SigningKey) encryptionKey := strings.TrimSpace(options.EncryptionKey) publicKeyURL := strings.TrimSpace(options.PublicKeyURL) if publicKeyURL != "" { parsed, parseErr := url.Parse(publicKeyURL) if parseErr != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || strings.ContainsAny(publicKeyURL, "\r\n") { return nil, "", "", "", errors.New("public-key URL must be a valid HTTPS URL without line breaks") } } switch mode { case "Plain": if signingKey != "" || encryptionKey != "" || publicKeyURL != "" { return nil, "", "", "", errors.New("plain mode does not accept cryptographic key fields") } case "Sign with Ed25519": if signingKey == "" { return nil, "", "", "", errors.New("an Ed25519 private key is required for signing") } case "Encrypt with age": if encryptionKey == "" { return nil, "", "", "", errors.New("an age recipient is required for encryption") } ciphertext, encryptErr := cryptokit.EncryptAge([]byte(body), encryptionKey) if encryptErr != nil { return nil, "", "", "", fmt.Errorf("encrypt article body: %w", encryptErr) } wireBody = normalizeCRLF(string(ciphertext)) contentType = "application/vnd.aegis.age" transferEncoding = "7bit" case "Encrypt with age and sign": if encryptionKey == "" || signingKey == "" { return nil, "", "", "", errors.New("an age recipient and an Ed25519 private key are required") } ciphertext, encryptErr := cryptokit.EncryptAge([]byte(body), encryptionKey) if encryptErr != nil { return nil, "", "", "", fmt.Errorf("encrypt article body: %w", encryptErr) } wireBody = normalizeCRLF(string(ciphertext)) contentType = "application/vnd.aegis.age" transferEncoding = "7bit" default: return nil, "", "", "", fmt.Errorf("unsupported article crypto mode %q", mode) } if mode == "Sign with Ed25519" || mode == "Encrypt with age and sign" { if signingKey == "" { return nil, "", "", "", errors.New("an Ed25519 private key is required for signing") } signature, signErr := cryptokit.SignEd25519([]byte(wireBody), signingKey) if signErr != nil { return nil, "", "", "", fmt.Errorf("sign article body: %w", signErr) } publicKey, publicErr := cryptokit.Ed25519PublicKey(signingKey) if publicErr != nil { return nil, "", "", "", fmt.Errorf("derive Ed25519 public key: %w", publicErr) } fingerprint, fingerprintErr := cryptokit.Ed25519PublicKeyFingerprint(publicKey) if fingerprintErr != nil { return nil, "", "", "", fmt.Errorf("fingerprint Ed25519 public key: %w", fingerprintErr) } if options.ExpectedPublicKey != "" { expectedKey, expectedErr := cryptokit.CanonicalEd25519PublicKey(options.ExpectedPublicKey) if expectedErr != nil { return nil, "", "", "", fmt.Errorf("canonicalize profile Ed25519 public key: %w", expectedErr) } if publicKey != expectedKey { return nil, "", "", "", errors.New("signing key does not match the VFace profile public key") } } headers = append(headers, "X-Aegis-Crypto-Version: 1", "X-Aegis-Signature: ed25519; "+signature, "X-Aegis-Public-Key: "+publicKey, "X-Ed25519-Sig: "+signature, "X-Aegis-Key-Fingerprint: "+fingerprint, ) if publicKeyURL != "" { headers = append(headers, foldHeader("X-Aegis-Public-Key-URL", publicKeyURL)) } } else if publicKeyURL != "" { return nil, "", "", "", errors.New("public-key URL requires an Ed25519 signature mode") } if mode == "Encrypt with age" || mode == "Encrypt with age and sign" { headers = append([]string{"X-Aegis-Crypto-Version: 1", "X-Aegis-Encryption: age"}, headers...) } return headers, contentType, transferEncoding, wireBody, nil } func normalizeCRLF(value string) string { value = strings.ReplaceAll(value, "\r\n", "\n") value = strings.ReplaceAll(value, "\r", "\n") return strings.ReplaceAll(value, "\n", "\r\n") } func foldHeader(name, value string) string { const maxHeaderValue = 76 if len(name)+2+len(value) <= 998 { return name + ": " + value } var out strings.Builder out.WriteString(name) out.WriteString(":") for len(value) > 0 { out.WriteString("\r\n ") limit := maxHeaderValue if len(value) < limit { limit = len(value) } out.WriteString(value[:limit]) value = value[limit:] } return out.String() } func formatFromHeader(value string) (string, error) { address, err := mail.ParseAddress(strings.TrimSpace(value)) if err != nil { return "", fmt.Errorf("invalid From address: %w", err) } if address.Name == "" { return address.Address, nil } if isASCII(address.Name) { return (&mail.Address{Name: address.Name, Address: address.Address}).String(), nil } return mime.QEncoding.Encode("UTF-8", address.Name) + " <" + address.Address + ">", nil } func isASCII(value string) bool { for i := 0; i < len(value); i++ { if value[i] > 0x7f { return false } } return true } func (a *application) filterGroups() { query := strings.ToLower(strings.TrimSpace(a.groupSearch.Text)) a.visibleGroups = a.visibleGroups[:0] for _, group := range a.groups { if query == "" || strings.Contains(strings.ToLower(group.Name), query) { a.visibleGroups = append(a.visibleGroups, group) } } if a.groupList != nil { a.groupList.Refresh() } } func (a *application) filterHeaders() { query := strings.ToLower(strings.TrimSpace(a.headerSearch.Text)) a.visibleHeader = a.visibleHeader[:0] for _, header := range a.headers { if query != "" && !strings.Contains(strings.ToLower(header.Subject), query) && !strings.Contains(strings.ToLower(header.From), query) && !strings.Contains(strings.ToLower(header.MessageID), query) && !strings.Contains(strings.ToLower(header.References), query) { continue } article := filter.Article{From: header.From, Subject: header.Subject, Newsgroups: a.loadedGroup, MessageID: header.MessageID, References: header.References, Read: a.headerRead(header)} result, err := filter.Evaluate(article, a.filterRules) if err != nil { a.status.SetText("Filter error: " + err.Error()) continue } if result.Hidden || result.MuteThread { continue } a.visibleHeader = append(a.visibleHeader, header) } if a.headerList != nil { a.headerList.Refresh() } } func articleKey(group string, header nntp.ArticleHeader) string { if header.MessageID != "" { return group + ":" + header.MessageID } return fmt.Sprintf("%s:%d", group, header.Number) } func (a *application) headerRead(header nntp.ArticleHeader) bool { if a.state == nil { return false } article, ok := a.state.Snapshot().Articles[articleKey(a.loadedGroup, header)] return ok && article.Read } func (a *application) headerBookmarked(header nntp.ArticleHeader) bool { if a.state == nil { return false } article, ok := a.state.Snapshot().Articles[articleKey(a.loadedGroup, header)] return ok && article.Bookmarked } func (a *application) refreshArticleActions() { if a.markRead == nil || a.bookmark == nil || a.reply == nil { return } if !a.hasSelectedHeader { a.markRead.Disable() a.bookmark.Disable() a.reply.Disable() return } a.markRead.Enable() a.bookmark.Enable() if strings.TrimSpace(a.articleText) == "" { a.reply.Disable() } else { a.reply.Enable() } if a.headerRead(a.selectedHeader) { a.markRead.SetText("Mark unread") } else { a.markRead.SetText("Mark read") } if a.headerBookmarked(a.selectedHeader) { a.bookmark.SetText("Remove bookmark") } else { a.bookmark.SetText("Bookmark") } } func (a *application) toggleRead() { if a.state == nil || !a.hasSelectedHeader { return } key := articleKey(a.loadedGroup, a.selectedHeader) _ = a.state.SetRead(key, !a.headerRead(a.selectedHeader)) _ = a.state.Save() a.refreshArticleActions() a.headerList.Refresh() } func (a *application) toggleBookmark() { if a.state == nil || !a.hasSelectedHeader { return } key := articleKey(a.loadedGroup, a.selectedHeader) _ = a.state.SetBookmarked(key, !a.headerBookmarked(a.selectedHeader)) _ = a.state.Save() a.refreshArticleActions() a.headerList.Refresh() } func (a *application) isSubscribed(group string) bool { for _, subscribed := range a.settings.Subscriptions { if subscribed == group { return true } } return false } func (a *application) setBusy(busy bool, message string) { if busy { a.progress.Show() } else { a.progress.Hide() } a.status.SetText(message) } func (a *application) asyncError(title string, err error) { fyne.Do(func() { a.setBusy(false, title+".") dialog.ShowError(fmt.Errorf("%s: %w", title, err), a.window) }) } func (a *application) currentClient() *nntp.Client { a.clientMu.RLock() defer a.clientMu.RUnlock() return a.client } func (a *application) replaceClient(client *nntp.Client) { a.clientMu.Lock() old := a.client a.client = client a.clientMu.Unlock() if old != nil { _ = old.Close() } } func (a *application) closeClient() { a.clientMu.Lock() client := a.client a.client = nil a.clientMu.Unlock() if client != nil { _ = client.Close() } } func normalizeGroups(value string) ([]string, error) { parts := strings.Split(value, ",") groups := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for _, part := range parts { group := strings.TrimSpace(part) if group == "" { continue } if err := config.ValidateGroupName(group); err != nil { return nil, err } if _, ok := seen[group]; ok { continue } seen[group] = struct{}{} groups = append(groups, group) } if len(groups) == 0 { return nil, errors.New("at least one newsgroup is required") } return groups, nil } func formatFrom(name, email string) string { if email == "" { return "" } return (&mail.Address{Name: name, Address: email}).String() } func formatCount(value int64) string { if value < 1000 { return strconv.FormatInt(value, 10) } parts := make([]string, 0, 4) for value > 0 { part := value % 1000 value /= 1000 if value > 0 { parts = append(parts, fmt.Sprintf("%03d", part)) } else { parts = append(parts, strconv.FormatInt(part, 10)) } } for left, right := 0, len(parts)-1; left < right; left, right = left+1, right-1 { parts[left], parts[right] = parts[right], parts[left] } return strings.Join(parts, " ") }