package ui import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "image" "io" "mime" "net/mail" "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 composeTo *widget.Entry composeFrom *widget.Entry composeSubject *widget.Entry composeReferences *widget.Entry composeFollowupTo *widget.Entry composeBody *widget.Entry composeCryptoMode *widget.Select composeCryptoSigningKey *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 vfacePublicKey *widget.Label vfaceKeyStatus *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.composeTo = widget.NewEntry() a.composeTo.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)) // Keep the identity visible in the normal foreground color. VFace, when // unlocked, still replaces this value before posting. a.composeCryptoMode = widget.NewSelect([]string{ "Plain", "Sign with Ed25519", }, nil) a.composeCryptoMode.SetSelected("Plain") a.composeCryptoSigningKey = widget.NewMultiLineEntry() a.composeCryptoSigningKey.SetPlaceHolder("Optional Ed25519 private key; VFace supplies it automatically") a.composeCryptoSigningKey.Wrapping = fyne.TextWrapOff post := widget.NewButtonWithIcon("Post article", theme.MailSendIcon(), a.postArticle) form := widget.NewForm( widget.NewFormItem("Newsgroups", a.composeGroups), widget.NewFormItem("Delivery", a.composeDelivery), widget.NewFormItem("To", a.composeTo), widget.NewFormItem("From", a.composeFrom), widget.NewFormItem("Subject", a.composeSubject), widget.NewFormItem("References", a.composeReferences), widget.NewFormItem("Followup-To", a.composeFollowupTo), widget.NewFormItem("Mode", a.composeCryptoMode), widget.NewFormItem("Ed25519 signing key", a.composeCryptoSigningKey), ) 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.vfacePublicKey = widget.NewLabel("") a.vfacePublicKey.Wrapping = fyne.TextWrapBreak a.vfacePublicKey.Selectable = true a.vfaceKeyStatus = widget.NewLabel("") a.vfaceKeyStatus.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("VFace creates an Ed25519 key pair. The public key is part of the identity; the private key remains encrypted in the local vault and is used for signing. Message encryption is intentionally not part of the Usenet client.") 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, a.vfacePublicKey, a.vfaceKeyStatus, 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.composeCryptoMode.SetSelected("Sign with Ed25519") 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.composeCryptoMode.SetSelected("Sign with Ed25519") 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.vfacePublicKey != nil { a.vfacePublicKey.SetText("") } if a.vfaceKeyStatus != nil { a.vfaceKeyStatus.SetText("") } if a.composeCryptoMode != nil && a.composeCryptoMode.Selected == "Sign with Ed25519" { a.composeCryptoMode.SetSelected("Plain") } 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) a.vfacePublicKey.SetText("Ed25519 public key (selectable):\n" + value.PublicKey) a.vfaceKeyStatus.SetText("Ed25519 key pair ready. Private key is encrypted in the local vault and available for signing.") } 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{"Ed25519", "YubiCrypt"}, nil) a.cryptoAlgorithm.SetSelected("Ed25519") a.cryptoOperation = widget.NewSelect([]string{"Sign", "Verify"}, nil) a.cryptoOperation.SetSelected("Sign") a.cryptoMessage = widget.NewMultiLineEntry() a.cryptoMessage.SetPlaceHolder("Message") 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("This panel is limited to signing and verification. 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"), 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") } 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" { 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 is required"), a.window) return } if algorithm == "YubiCrypt" && operation == "Sign" && strings.TrimSpace(secret) == "" { dialog.ShowError(errors.New("YubiKey PIV PIN 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 "Ed25519": switch operation { case "Sign": result, err = cryptokit.SignEd25519(message, primary) case "Verify": err = cryptokit.VerifyEd25519(message, primary, secondary) result = "Ed25519 signature verified." } 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) } } fyne.Do(func() { a.setBusy(false, "Signing 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("Default To", 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", "To", "Date", "Newsgroups", "Subject", "Message-ID", "References", "Followup-To", "Reply-To", "Organization", "User-Agent", "MIME-Version", "Content-Type", "Content-Transfer-Encoding", "Face", "X-Signature", "X-Aegis-Signature", "X-Aegis-Public-Key", "X-Aegis-Key-Fingerprint", "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 to := strings.TrimSpace(a.composeTo.Text) if delivery == "SMTP mail2news" { if to == "" { to = strings.TrimSpace(settings.SMTPRecipient) } if to == "" { dialog.ShowError(errors.New("To address is required for SMTP delivery"), a.window) return } if _, err := mail.ParseAddress(to); err != nil { dialog.ShowError(fmt.Errorf("invalid To address: %w", err), a.window) return } recipients = []string{to} } else if to != "" { if _, err := mail.ParseAddress(to); err != nil { dialog.ShowError(fmt.Errorf("invalid To address: %w", err), a.window) return } } article, err := buildArticleWithIdentityHeaders(groups, from, subject, a.composeBody.Text, identityHeaders, articleCryptoOptions{ Mode: a.composeCryptoMode.Selected, To: to, SigningKey: signingKey, 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.composeTo.SetText(settings.SMTPRecipient) a.composeCryptoSigningKey.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 To string SigningKey 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 to := strings.TrimSpace(options.To); to != "" { if strings.ContainsAny(to, "\r\n") { return "", errors.New("To must not contain line breaks") } if _, err := mail.ParseAddress(to); err != nil { return "", fmt.Errorf("invalid To address: %w", err) } headers = append(headers, foldHeader("To", to)) } 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) switch mode { case "Plain": case "Sign with Ed25519": if signingKey == "" { return nil, "", "", "", errors.New("an Ed25519 private key is required for signing") } default: return nil, "", "", "", fmt.Errorf("unsupported article signing mode %q", mode) } if mode == "Sign with Ed25519" { 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, ) } 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, " ") }