Compare commits

...

15 Commits

Author SHA1 Message Date
mattn
ef83b48ca0 Merge pull request #3 from mattn/copilot/fix-cgo-enabled-windows-builds
[WIP] Fix CGO_ENABLED setting for Windows builds
2026-01-17 16:29:19 +09:00
copilot-swe-agent[bot]
766598eee6 Set CGO_ENABLED=0 for Windows builds to fix cross-compilation
Co-authored-by: mattn <10111+mattn@users.noreply.github.com>
2026-01-17 07:28:30 +00:00
copilot-swe-agent[bot]
413b5cf161 Initial plan 2026-01-17 07:26:27 +00:00
mattn
c2969ba503 Merge pull request #2 from mattn/copilot/fix-fuse-installation-issue
Switch to softprops/action-gh-release and add FUSE dependencies
2026-01-17 16:18:43 +09:00
copilot-swe-agent[bot]
235e16d34b Switch build-all-for-all to softprops/action-gh-release
Co-authored-by: mattn <10111+mattn@users.noreply.github.com>
2026-01-17 07:15:33 +00:00
copilot-swe-agent[bot]
e44dd08527 Add FUSE dependencies installation to build-all-for-all job
Co-authored-by: mattn <10111+mattn@users.noreply.github.com>
2026-01-17 07:02:39 +00:00
copilot-swe-agent[bot]
d856f54394 Initial plan 2026-01-17 07:00:46 +00:00
mattn
d015e979aa Merge pull request #1 from mattn/fix-workflows
Fix workflows
2026-01-17 15:43:19 +09:00
Yasuhiro Matsumoto
120a92920e switch to softprops/action-gh-release 2026-01-17 15:41:36 +09:00
fiatjaf
c6da13649d hopefully eliminate the weird case of cron and githubactions calling nak with an empty stdin and causing it to do nothing.
closes https://github.com/fiatjaf/nak/issues/90
2026-01-16 16:11:07 -03:00
Yasuhiro Matsumoto
acd6227dd0 fix darwin build 2026-01-16 15:17:29 -03:00
mattn
00fbda9af7 use native runner and install macfuse 2026-01-16 13:43:19 -03:00
fiatjaf
e838de9b72 fs: move everything to the top-level directory. 2026-01-16 12:34:09 -03:00
fiatjaf
6dfbed4413 fs: just some renames. 2026-01-16 12:18:32 -03:00
fiatjaf
0e283368ed bunker: authorize preexisting keys first. 2026-01-16 12:15:07 -03:00
9 changed files with 229 additions and 203 deletions

View File

@@ -9,44 +9,65 @@ permissions:
contents: write contents: write
jobs: jobs:
make-release:
runs-on: ubuntu-latest
steps:
- uses: actions/create-release@latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
build-all-for-all: build-all-for-all:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs:
- make-release
strategy: strategy:
matrix: matrix:
goos: [linux, freebsd, darwin, windows] goos: [linux, freebsd, windows]
goarch: [amd64, arm64, riscv64] goarch: [amd64, arm64, riscv64]
exclude: exclude:
- goarch: arm64 - goarch: arm64
goos: windows goos: windows
- goarch: riscv64 - goarch: riscv64
goos: windows goos: windows
- goarch: riscv64
goos: darwin
- goarch: arm64 - goarch: arm64
goos: freebsd goos: freebsd
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: wangyoucao577/go-release-action@v1.40 - name: Set up Go
uses: actions/setup-go@v4
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} go-version: 'stable'
goos: ${{ matrix.goos }} - name: Install FUSE dependencies
goarch: ${{ matrix.goarch }} run: |
ldflags: -X main.version=${{ github.ref_name }} sudo apt-get update
overwrite: true sudo apt-get install -y libfuse-dev
md5sum: false - name: Build binary
sha256sum: false env:
compress_assets: false GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: ${{ matrix.goos == 'windows' && '0' || '1' }}
run: |
go build -ldflags "-X main.version=${{ github.ref_name }}" -o nak-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
- name: Upload Release Asset
uses: softprops/action-gh-release@v1
with:
files: ./nak-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
build-darwin:
runs-on: macos-latest
strategy:
matrix:
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 'stable'
- name: Install macFUSE
run: brew install --cask macfuse
- name: Build binary
env:
GOOS: darwin
GOARCH: ${{ matrix.goarch }}
run: |
go build -ldflags "-X main.version=${{ github.ref_name }}" -o nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
- name: Upload Release Asset
uses: softprops/action-gh-release@v1
with:
files: ./nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
smoke-test-linux-amd64: smoke-test-linux-amd64:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
@@ -102,7 +123,7 @@ jobs:
# test NIP-49 key encryption/decryption # test NIP-49 key encryption/decryption
echo "testing NIP-49 key encryption/decryption..." echo "testing NIP-49 key encryption/decryption..."
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword") ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
echo "encrypted key: ${ENCRYPTED_KEY:0:20}..." echo "encrypted key: ${ENCRYPTED_KEY: 0:20}..."
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword") DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
echo "nip-49 encryption/decryption test failed!" echo "nip-49 encryption/decryption test failed!"

View File

@@ -329,6 +329,10 @@ var bunker = &cli.Command{
// asking user for authorization // asking user for authorization
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool { signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
if slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) {
return true
}
if secret == newSecret { if secret == newSecret {
// store this key // store this key
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from) config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
@@ -343,9 +347,11 @@ var bunker = &cli.Command{
if persist != nil { if persist != nil {
persist() persist()
} }
return true
} }
return slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) return false
} }
for ie := range events { for ie := range events {

View File

@@ -155,6 +155,7 @@ example:
os.Exit(3) os.Exit(3)
} }
} }
kr, sec, err := gatherKeyerFromArguments(ctx, c) kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil { if err != nil {
return err return err

9
fs.go
View File

@@ -13,7 +13,6 @@ import (
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer" "fiatjaf.com/nostr/keyer"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/fiatjaf/nak/nostrfs"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse" "github.com/winfsp/cgofuse/fuse"
) )
@@ -63,7 +62,7 @@ var fsCmd = &cli.Command{
apat = time.Hour * 24 * 365 * 3 apat = time.Hour * 24 * 365 * 3
} }
root := nostrfs.NewNostrRoot( root := NewFSRoot(
context.WithValue( context.WithValue(
context.WithValue( context.WithValue(
ctx, ctx,
@@ -74,7 +73,7 @@ var fsCmd = &cli.Command{
sys, sys,
kr, kr,
mountpoint, mountpoint,
nostrfs.Options{ FSOptions{
AutoPublishNotesTimeout: apnt, AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat, AutoPublishArticlesTimeout: apat,
}, },
@@ -83,12 +82,12 @@ var fsCmd = &cli.Command{
// create the server // create the server
log("- mounting at %s... ", color.HiCyanString(mountpoint)) log("- mounting at %s... ", color.HiCyanString(mountpoint))
// Create cgofuse host // create cgofuse host
host := fuse.NewFileSystemHost(root) host := fuse.NewFileSystemHost(root)
host.SetCapReaddirPlus(true) host.SetCapReaddirPlus(true)
host.SetUseIno(true) host.SetUseIno(true)
// Mount the filesystem // mount the filesystem
mountArgs := []string{"-s", mountpoint} mountArgs := []string{"-s", mountpoint}
if isVerbose { if isVerbose {
mountArgs = append([]string{"-d"}, mountArgs...) mountArgs = append([]string{"-d"}, mountArgs...)

View File

@@ -15,6 +15,6 @@ var fsCmd = &cli.Command{
Description: `doesn't work on Windows and OpenBSD.`, Description: `doesn't work on Windows and OpenBSD.`,
DisableSliceFlagSeparator: true, DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("this doesn't work on Windows and OpenBSD.") return fmt.Errorf("this doesn't work on OpenBSD.")
}, },
} }

View File

@@ -1,8 +1,8 @@
package nostrfs package main
import ( import (
"context" "context"
"encoding/json" stdjson "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"path/filepath" "path/filepath"
@@ -17,27 +17,27 @@ import (
"github.com/winfsp/cgofuse/fuse" "github.com/winfsp/cgofuse/fuse"
) )
type Options struct { type FSOptions struct {
AutoPublishNotesTimeout time.Duration AutoPublishNotesTimeout time.Duration
AutoPublishArticlesTimeout time.Duration AutoPublishArticlesTimeout time.Duration
} }
type NostrRoot struct { type FSRoot struct {
fuse.FileSystemBase fuse.FileSystemBase
ctx context.Context ctx context.Context
sys *sdk.System sys *sdk.System
rootPubKey nostr.PubKey rootPubKey nostr.PubKey
signer nostr.Signer signer nostr.Signer
opts Options opts FSOptions
mountpoint string mountpoint string
mu sync.RWMutex mu sync.RWMutex
nodes map[string]*Node // path -> node nodes map[string]*FSNode // path -> node
nextIno uint64 nextIno uint64
pendingNotes map[string]*time.Timer // path -> auto-publish timer pendingNotes map[string]*time.Timer // path -> auto-publish timer
} }
type Node struct { type FSNode struct {
ino uint64 ino uint64
path string path string
name string name string
@@ -46,14 +46,14 @@ type Node struct {
mode uint32 mode uint32
mtime time.Time mtime time.Time
data []byte data []byte
children map[string]*Node children map[string]*FSNode
loadFunc func() ([]byte, error) // for lazy loading loadFunc func() ([]byte, error) // for lazy loading
loaded bool loaded bool
} }
var _ fuse.FileSystemInterface = (*NostrRoot)(nil) var _ fuse.FileSystemInterface = (*FSRoot)(nil)
func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o Options) *NostrRoot { func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o FSOptions) *FSRoot {
var system *sdk.System var system *sdk.System
if sys != nil { if sys != nil {
system = sys.(*sdk.System) system = sys.(*sdk.System)
@@ -71,37 +71,37 @@ func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountp
abs, _ := filepath.Abs(mountpoint) abs, _ := filepath.Abs(mountpoint)
root := &NostrRoot{ root := &FSRoot{
ctx: ctx, ctx: ctx,
sys: system, sys: system,
rootPubKey: pubkey, rootPubKey: pubkey,
signer: signer, signer: signer,
opts: o, opts: o,
mountpoint: abs, mountpoint: abs,
nodes: make(map[string]*Node), nodes: make(map[string]*FSNode),
nextIno: 2, // 1 is reserved for root nextIno: 2, // 1 is reserved for root
pendingNotes: make(map[string]*time.Timer), pendingNotes: make(map[string]*time.Timer),
} }
// Initialize root directory // initialize root directory
rootNode := &Node{ rootNode := &FSNode{
ino: 1, ino: 1,
path: "/", path: "/",
name: "", name: "",
isDir: true, isDir: true,
mode: fuse.S_IFDIR | 0755, mode: fuse.S_IFDIR | 0755,
mtime: time.Now(), mtime: time.Now(),
children: make(map[string]*Node), children: make(map[string]*FSNode),
} }
root.nodes["/"] = rootNode root.nodes["/"] = rootNode
// Start async initialization // start async initialization
go root.initialize() go root.initialize()
return root return root
} }
func (r *NostrRoot) initialize() { func (r *FSRoot) initialize() {
if r.rootPubKey == nostr.ZeroPK { if r.rootPubKey == nostr.ZeroPK {
return return
} }
@@ -109,14 +109,14 @@ func (r *NostrRoot) initialize() {
log := r.getLog() log := r.getLog()
time.Sleep(time.Millisecond * 100) time.Sleep(time.Millisecond * 100)
// Fetch follow list // fetch follow list
fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey) fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey)
log("- fetched %d contacts\n", len(fl.Items)) log("- fetched %d contacts\n", len(fl.Items))
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
// Add our contacts // add our contacts
for _, f := range fl.Items { for _, f := range fl.Items {
npub := nip19.EncodeNpub(f.Pubkey) npub := nip19.EncodeNpub(f.Pubkey)
if _, exists := r.nodes["/"+npub]; !exists { if _, exists := r.nodes["/"+npub]; !exists {
@@ -124,14 +124,14 @@ func (r *NostrRoot) initialize() {
} }
} }
// Add ourselves // add ourselves
npub := nip19.EncodeNpub(r.rootPubKey) npub := nip19.EncodeNpub(r.rootPubKey)
if _, exists := r.nodes["/"+npub]; !exists { if _, exists := r.nodes["/"+npub]; !exists {
r.createNpubDirLocked(npub, r.rootPubKey, r.signer) r.createNpubDirLocked(npub, r.rootPubKey, r.signer)
} }
// Add @me symlink (for now, just create a text file pointing to our npub) // add @me symlink (for now, just create a text file pointing to our npub)
meNode := &Node{ meNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: "/@me", path: "/@me",
name: "@me", name: "@me",
@@ -146,19 +146,19 @@ func (r *NostrRoot) initialize() {
r.nodes["/"].children["@me"] = meNode r.nodes["/"].children["@me"] = meNode
} }
func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) { func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey) pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
if pm.Event == nil { if pm.Event == nil {
return return
} }
// Use the content field which contains the actual profile JSON // use the content field which contains the actual profile JSON
metadataJ := []byte(pm.Event.Content) metadataJ := []byte(pm.Event.Content)
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
metadataNode := &Node{ metadataNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: dirPath + "/metadata.json", path: dirPath + "/metadata.json",
name: "metadata.json", name: "metadata.json",
@@ -175,13 +175,13 @@ func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
} }
} }
func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) { func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey) pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
if pm.Event == nil || pm.Picture == "" { if pm.Event == nil || pm.Picture == "" {
return return
} }
// Download picture // download picture
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20) ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
defer cancel() defer cancel()
@@ -200,7 +200,7 @@ func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
return return
} }
// Read image data // read image data
imageData := make([]byte, 0, 1024*1024) // 1MB initial capacity imageData := make([]byte, 0, 1024*1024) // 1MB initial capacity
buf := make([]byte, 32*1024) buf := make([]byte, 32*1024)
for { for {
@@ -220,7 +220,7 @@ func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
return return
} }
// Detect file extension from content-type or URL // detect file extension from content-type or URL
ext := "png" ext := "png"
if ct := resp.Header.Get("Content-Type"); ct != "" { if ct := resp.Header.Get("Content-Type"); ct != "" {
switch ct { switch ct {
@@ -239,7 +239,7 @@ func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
defer r.mu.Unlock() defer r.mu.Unlock()
picturePath := dirPath + "/picture." + ext picturePath := dirPath + "/picture." + ext
pictureNode := &Node{ pictureNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: picturePath, path: picturePath,
name: "picture." + ext, name: "picture." + ext,
@@ -256,11 +256,11 @@ func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
} }
} }
func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) { func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) {
ctx, cancel := context.WithTimeout(r.ctx, time.Second*10) ctx, cancel := context.WithTimeout(r.ctx, time.Second*10)
defer cancel() defer cancel()
// Get relays for authors // get relays for authors
var relays []string var relays []string
if len(filter.Authors) > 0 { if len(filter.Authors) > 0 {
relays = r.sys.FetchOutboxRelays(ctx, filter.Authors[0], 3) relays = r.sys.FetchOutboxRelays(ctx, filter.Authors[0], 3)
@@ -272,12 +272,12 @@ func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
log := r.getLog() log := r.getLog()
log("- fetching events for %s from %v\n", dirPath, relays) log("- fetching events for %s from %v\n", dirPath, relays)
// Fetch events // fetch events
events := make([]*nostr.Event, 0) events := make([]*nostr.Event, 0)
for ie := range r.sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{ for ie := range r.sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-fs", Label: "nak-fs",
}) { }) {
// Make a copy to avoid pointer issues with loop variable // make a copy to avoid pointer issues with loop variable
evt := ie.Event evt := ie.Event
events = append(events, &evt) events = append(events, &evt)
if len(events) >= int(filter.Limit) { if len(events) >= int(filter.Limit) {
@@ -295,14 +295,14 @@ func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
return return
} }
// Track oldest timestamp for pagination // track oldest timestamp for pagination
var oldestTimestamp nostr.Timestamp var oldestTimestamp nostr.Timestamp
if len(events) > 0 { if len(events) > 0 {
oldestTimestamp = events[len(events)-1].CreatedAt oldestTimestamp = events[len(events)-1].CreatedAt
} }
for _, evt := range events { for _, evt := range events {
// Create filename based on event // create filename based on event
filename := r.eventToFilename(evt) filename := r.eventToFilename(evt)
filePath := dirPath + "/" + filename filePath := dirPath + "/" + filename
@@ -315,7 +315,7 @@ func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
content = "(empty)" content = "(empty)"
} }
fileNode := &Node{ fileNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: filePath, path: filePath,
name: filename, name: filename,
@@ -330,9 +330,9 @@ func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
dir.children[filename] = fileNode dir.children[filename] = fileNode
} }
// Add "more" file for pagination if we got a full page // add "more" file for pagination if we got a full page
if len(events) >= int(filter.Limit) { if len(events) >= int(filter.Limit) {
moreFile := &Node{ moreFile := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: dirPath + "/.more", path: dirPath + "/.more",
name: ".more", name: ".more",
@@ -342,7 +342,7 @@ func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
data: []byte(fmt.Sprintf("Read this file to load more events (until: %d)\n", oldestTimestamp)), data: []byte(fmt.Sprintf("Read this file to load more events (until: %d)\n", oldestTimestamp)),
size: int64(len(fmt.Sprintf("Read this file to load more events (until: %d)\n", oldestTimestamp))), size: int64(len(fmt.Sprintf("Read this file to load more events (until: %d)\n", oldestTimestamp))),
loadFunc: func() ([]byte, error) { loadFunc: func() ([]byte, error) {
// When .more is read, fetch next page // when .more is read, fetch next page
newFilter := filter newFilter := filter
newFilter.Until = oldestTimestamp newFilter.Until = oldestTimestamp
go r.fetchEvents(dirPath, newFilter) go r.fetchEvents(dirPath, newFilter)
@@ -355,24 +355,24 @@ func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
} }
} }
func (r *NostrRoot) eventToFilename(evt *nostr.Event) string { func (r *FSRoot) eventToFilename(evt *nostr.Event) string {
// Use event ID first 8 chars + extension based on kind // use event ID first 8 chars + extension based on kind
ext := kindToExtension(evt.Kind) ext := kindToExtension(evt.Kind)
// Get hex representation of event ID // get hex representation of event ID
// evt.ID.String() may return format like ":1234abcd" so use Hex() or remove colons // evt.ID.String() may return format like ":1234abcd" so use Hex() or remove colons
idHex := evt.ID.Hex() idHex := evt.ID.Hex()
if len(idHex) > 8 { if len(idHex) > 8 {
idHex = idHex[:8] idHex = idHex[:8]
} }
// For articles, try to use title // for articles, try to use title
if evt.Kind == 30023 || evt.Kind == 30818 { if evt.Kind == 30023 || evt.Kind == 30818 {
for _, tag := range evt.Tags { for _, tag := range evt.Tags {
if len(tag) >= 2 && tag[0] == "title" { if len(tag) >= 2 && tag[0] == "title" {
titleStr := tag[1] titleStr := tag[1]
if titleStr != "" { if titleStr != "" {
// Sanitize title for filename // sanitize title for filename
name := strings.Map(func(r rune) rune { name := strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
return '-' return '-'
@@ -391,35 +391,35 @@ func (r *NostrRoot) eventToFilename(evt *nostr.Event) string {
return fmt.Sprintf("%s.%s", idHex, ext) return fmt.Sprintf("%s.%s", idHex, ext)
} }
func (r *NostrRoot) getLog() func(string, ...interface{}) { func (r *FSRoot) getLog() func(string, ...interface{}) {
if log := r.ctx.Value("log"); log != nil { if log := r.ctx.Value("log"); log != nil {
return log.(func(string, ...interface{})) return log.(func(string, ...interface{}))
} }
return func(string, ...interface{}) {} return func(string, ...interface{}) {}
} }
func (r *NostrRoot) getNode(path string) *Node { func (r *FSRoot) getNode(path string) *FSNode {
originalPath := path originalPath := path
// Normalize path // normalize path
if path == "" { if path == "" {
path = "/" path = "/"
} }
// Convert Windows backslashes to forward slashes // convert Windows backslashes to forward slashes
path = strings.ReplaceAll(path, "\\", "/") path = strings.ReplaceAll(path, "\\", "/")
// Ensure path starts with / // ensure path starts with /
if !strings.HasPrefix(path, "/") { if !strings.HasPrefix(path, "/") {
path = "/" + path path = "/" + path
} }
// Remove trailing slash except for root // remove trailing slash except for root
if path != "/" && strings.HasSuffix(path, "/") { if path != "/" && strings.HasSuffix(path, "/") {
path = strings.TrimSuffix(path, "/") path = strings.TrimSuffix(path, "/")
} }
// Debug logging // debug logging
if r.ctx.Value("logverbose") != nil { if r.ctx.Value("logverbose") != nil {
logv := r.ctx.Value("logverbose").(func(string, ...interface{})) logv := r.ctx.Value("logverbose").(func(string, ...interface{}))
logv("getNode: original='%s' normalized='%s'\n", originalPath, path) logv("getNode: original='%s' normalized='%s'\n", originalPath, path)
@@ -430,7 +430,7 @@ func (r *NostrRoot) getNode(path string) *Node {
node := r.nodes[path] node := r.nodes[path]
// Debug: if not found, show similar paths // debug: if not found, show similar paths
if node == nil && r.ctx.Value("logverbose") != nil { if node == nil && r.ctx.Value("logverbose") != nil {
logv := r.ctx.Value("logverbose").(func(string, ...interface{})) logv := r.ctx.Value("logverbose").(func(string, ...interface{}))
logv("getNode: NOT FOUND '%s'\n", path) logv("getNode: NOT FOUND '%s'\n", path)
@@ -451,11 +451,11 @@ func (r *NostrRoot) getNode(path string) *Node {
return node return node
} }
func (r *NostrRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int { func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
node := r.getNode(path) node := r.getNode(path)
// If node doesn't exist, try dynamic lookup // if node doesn't exist, try dynamic lookup
// But skip for special files starting with @ or . // but skip for special files starting with @ or .
if node == nil { if node == nil {
basename := filepath.Base(path) basename := filepath.Base(path)
if !strings.HasPrefix(basename, "@") && !strings.HasPrefix(basename, ".") { if !strings.HasPrefix(basename, "@") && !strings.HasPrefix(basename, ".") {
@@ -480,14 +480,14 @@ func (r *NostrRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
} }
// dynamicLookup tries to create nodes on-demand for npub/note/nevent paths // dynamicLookup tries to create nodes on-demand for npub/note/nevent paths
func (r *NostrRoot) dynamicLookup(path string) bool { func (r *FSRoot) dynamicLookup(path string) bool {
// Normalize path // normalize path
path = strings.ReplaceAll(path, "\\", "/") path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") { if !strings.HasPrefix(path, "/") {
path = "/" + path path = "/" + path
} }
// Get the first component after root // get the first component after root
parts := strings.Split(strings.TrimPrefix(path, "/"), "/") parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
if len(parts) == 0 { if len(parts) == 0 {
return false return false
@@ -495,10 +495,10 @@ func (r *NostrRoot) dynamicLookup(path string) bool {
name := parts[0] name := parts[0]
// Try to decode as nostr pointer // try to decode as nostr pointer
pointer, err := nip19.ToPointer(name) pointer, err := nip19.ToPointer(name)
if err != nil { if err != nil {
// Try NIP-05 // try NIP-05
if strings.Contains(name, "@") && !strings.HasPrefix(name, "@") { if strings.Contains(name, "@") && !strings.HasPrefix(name, "@") {
ctx, cancel := context.WithTimeout(r.ctx, time.Second*5) ctx, cancel := context.WithTimeout(r.ctx, time.Second*5)
defer cancel() defer cancel()
@@ -515,19 +515,19 @@ func (r *NostrRoot) dynamicLookup(path string) bool {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
// Check if already exists // check if already exists
if _, exists := r.nodes["/"+name]; exists { if _, exists := r.nodes["/"+name]; exists {
return true return true
} }
switch p := pointer.(type) { switch p := pointer.(type) {
case nostr.ProfilePointer: case nostr.ProfilePointer:
// Create npub directory dynamically // create npub directory dynamically
r.createNpubDirLocked(name, p.PublicKey, nil) r.createNpubDirLocked(name, p.PublicKey, nil)
return true return true
case nostr.EventPointer: case nostr.EventPointer:
// Create event directory dynamically // create event directory dynamically
return r.createEventDirLocked(name, p) return r.createEventDirLocked(name, p)
default: default:
@@ -535,30 +535,30 @@ func (r *NostrRoot) dynamicLookup(path string) bool {
} }
} }
func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) { func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
dirPath := "/" + npub dirPath := "/" + npub
// Check if already exists // check if already exists
if _, exists := r.nodes[dirPath]; exists { if _, exists := r.nodes[dirPath]; exists {
return return
} }
dirNode := &Node{ dirNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: dirPath, path: dirPath,
name: npub, name: npub,
isDir: true, isDir: true,
mode: fuse.S_IFDIR | 0755, mode: fuse.S_IFDIR | 0755,
mtime: time.Now(), mtime: time.Now(),
children: make(map[string]*Node), children: make(map[string]*FSNode),
} }
r.nextIno++ r.nextIno++
r.nodes[dirPath] = dirNode r.nodes[dirPath] = dirNode
r.nodes["/"].children[npub] = dirNode r.nodes["/"].children[npub] = dirNode
// Add pubkey file // add pubkey file
pubkeyData := []byte(pubkey.Hex() + "\n") pubkeyData := []byte(pubkey.Hex() + "\n")
pubkeyNode := &Node{ pubkeyNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: dirPath + "/pubkey", path: dirPath + "/pubkey",
name: "pubkey", name: "pubkey",
@@ -572,78 +572,78 @@ func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer
r.nodes[dirPath+"/pubkey"] = pubkeyNode r.nodes[dirPath+"/pubkey"] = pubkeyNode
dirNode.children["pubkey"] = pubkeyNode dirNode.children["pubkey"] = pubkeyNode
// Fetch metadata asynchronously // fetch metadata asynchronously
go r.fetchMetadata(dirPath, pubkey) go r.fetchMetadata(dirPath, pubkey)
// Add notes directory // add notes directory
r.createViewDirLocked(dirPath, "notes", nostr.Filter{ r.createViewDirLocked(dirPath, "notes", nostr.Filter{
Kinds: []nostr.Kind{1}, Kinds: []nostr.Kind{1},
Authors: []nostr.PubKey{pubkey}, Authors: []nostr.PubKey{pubkey},
Limit: 50, Limit: 50,
}) })
// Add articles directory // add articles directory
r.createViewDirLocked(dirPath, "articles", nostr.Filter{ r.createViewDirLocked(dirPath, "articles", nostr.Filter{
Kinds: []nostr.Kind{30023}, Kinds: []nostr.Kind{30023},
Authors: []nostr.PubKey{pubkey}, Authors: []nostr.PubKey{pubkey},
Limit: 50, Limit: 50,
}) })
// Add comments directory // add comments directory
r.createViewDirLocked(dirPath, "comments", nostr.Filter{ r.createViewDirLocked(dirPath, "comments", nostr.Filter{
Kinds: []nostr.Kind{1111}, Kinds: []nostr.Kind{1111},
Authors: []nostr.PubKey{pubkey}, Authors: []nostr.PubKey{pubkey},
Limit: 50, Limit: 50,
}) })
// Add highlights directory // add highlights directory
r.createViewDirLocked(dirPath, "highlights", nostr.Filter{ r.createViewDirLocked(dirPath, "highlights", nostr.Filter{
Kinds: []nostr.Kind{9802}, Kinds: []nostr.Kind{9802},
Authors: []nostr.PubKey{pubkey}, Authors: []nostr.PubKey{pubkey},
Limit: 50, Limit: 50,
}) })
// Add photos directory // add photos directory
r.createViewDirLocked(dirPath, "photos", nostr.Filter{ r.createViewDirLocked(dirPath, "photos", nostr.Filter{
Kinds: []nostr.Kind{20}, Kinds: []nostr.Kind{20},
Authors: []nostr.PubKey{pubkey}, Authors: []nostr.PubKey{pubkey},
Limit: 50, Limit: 50,
}) })
// Add videos directory // add videos directory
r.createViewDirLocked(dirPath, "videos", nostr.Filter{ r.createViewDirLocked(dirPath, "videos", nostr.Filter{
Kinds: []nostr.Kind{21, 22}, Kinds: []nostr.Kind{21, 22},
Authors: []nostr.PubKey{pubkey}, Authors: []nostr.PubKey{pubkey},
Limit: 50, Limit: 50,
}) })
// Add wikis directory // add wikis directory
r.createViewDirLocked(dirPath, "wikis", nostr.Filter{ r.createViewDirLocked(dirPath, "wikis", nostr.Filter{
Kinds: []nostr.Kind{30818}, Kinds: []nostr.Kind{30818},
Authors: []nostr.PubKey{pubkey}, Authors: []nostr.PubKey{pubkey},
Limit: 50, Limit: 50,
}) })
// Fetch profile picture asynchronously // fetch profile picture asynchronously
go r.fetchProfilePicture(dirPath, pubkey) go r.fetchProfilePicture(dirPath, pubkey)
} }
func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) { func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
dirPath := parentPath + "/" + name dirPath := parentPath + "/" + name
// Check if already exists // check if already exists
if _, exists := r.nodes[dirPath]; exists { if _, exists := r.nodes[dirPath]; exists {
return return
} }
dirNode := &Node{ dirNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: dirPath, path: dirPath,
name: name, name: name,
isDir: true, isDir: true,
mode: fuse.S_IFDIR | 0755, mode: fuse.S_IFDIR | 0755,
mtime: time.Now(), mtime: time.Now(),
children: make(map[string]*Node), children: make(map[string]*FSNode),
} }
r.nextIno++ r.nextIno++
@@ -652,14 +652,14 @@ func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Fi
parent.children[name] = dirNode parent.children[name] = dirNode
} }
// Fetch events asynchronously // fetch events asynchronously
go r.fetchEvents(dirPath, filter) go r.fetchEvents(dirPath, filter)
} }
func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool { func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
dirPath := "/" + name dirPath := "/" + name
// Fetch the event // fetch the event
ctx, cancel := context.WithTimeout(r.ctx, time.Second*10) ctx, cancel := context.WithTimeout(r.ctx, time.Second*10)
defer cancel() defer cancel()
@@ -676,7 +676,7 @@ func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer
for ie := range r.sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{ for ie := range r.sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-fs-event", Label: "nak-fs-event",
}) { }) {
// Make a copy to avoid pointer issues // make a copy to avoid pointer issues
evtCopy := ie.Event evtCopy := ie.Event
evt = &evtCopy evt = &evtCopy
break break
@@ -686,24 +686,24 @@ func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer
return false return false
} }
// Create event directory // create event directory
dirNode := &Node{ dirNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: dirPath, path: dirPath,
name: name, name: name,
isDir: true, isDir: true,
mode: fuse.S_IFDIR | 0755, mode: fuse.S_IFDIR | 0755,
mtime: time.Unix(int64(evt.CreatedAt), 0), mtime: time.Unix(int64(evt.CreatedAt), 0),
children: make(map[string]*Node), children: make(map[string]*FSNode),
} }
r.nextIno++ r.nextIno++
r.nodes[dirPath] = dirNode r.nodes[dirPath] = dirNode
r.nodes["/"].children[name] = dirNode r.nodes["/"].children[name] = dirNode
// Add content file // add content file
ext := kindToExtension(evt.Kind) ext := kindToExtension(evt.Kind)
contentPath := dirPath + "/content." + ext contentPath := dirPath + "/content." + ext
contentNode := &Node{ contentNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: contentPath, path: contentPath,
name: "content." + ext, name: "content." + ext,
@@ -717,10 +717,10 @@ func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer
r.nodes[contentPath] = contentNode r.nodes[contentPath] = contentNode
dirNode.children["content."+ext] = contentNode dirNode.children["content."+ext] = contentNode
// Add event.json // add event.json
eventJSON, _ := json.MarshalIndent(evt, "", " ") eventJSON, _ := stdjson.MarshalIndent(evt, "", " ")
eventJSONPath := dirPath + "/event.json" eventJSONPath := dirPath + "/event.json"
eventJSONNode := &Node{ eventJSONNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: eventJSONPath, path: eventJSONPath,
name: "event.json", name: "event.json",
@@ -737,11 +737,11 @@ func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer
return true return true
} }
func (r *NostrRoot) Readdir(path string, func (r *FSRoot) Readdir(path string,
fill func(name string, stat *fuse.Stat_t, ofst int64) bool, fill func(name string, stat *fuse.Stat_t, ofst int64) bool,
ofst int64, ofst int64,
fh uint64) int { fh uint64,
) int {
node := r.getNode(path) node := r.getNode(path)
if node == nil || !node.isDir { if node == nil || !node.isDir {
return -fuse.ENOENT return -fuse.ENOENT
@@ -768,8 +768,8 @@ func (r *NostrRoot) Readdir(path string,
return 0 return 0
} }
func (r *NostrRoot) Open(path string, flags int) (int, uint64) { func (r *FSRoot) Open(path string, flags int) (int, uint64) {
// Log the open attempt // log the open attempt
if r.ctx.Value("logverbose") != nil { if r.ctx.Value("logverbose") != nil {
logv := r.ctx.Value("logverbose").(func(string, ...interface{})) logv := r.ctx.Value("logverbose").(func(string, ...interface{}))
logv("Open: path='%s' flags=%d\n", path, flags) logv("Open: path='%s' flags=%d\n", path, flags)
@@ -783,7 +783,7 @@ func (r *NostrRoot) Open(path string, flags int) (int, uint64) {
return -fuse.EISDIR, ^uint64(0) return -fuse.EISDIR, ^uint64(0)
} }
// Load data if needed // load data if needed
if node.loadFunc != nil && !node.loaded { if node.loadFunc != nil && !node.loaded {
r.mu.Lock() r.mu.Lock()
if !node.loaded { if !node.loaded {
@@ -799,7 +799,7 @@ func (r *NostrRoot) Open(path string, flags int) (int, uint64) {
return 0, node.ino return 0, node.ino
} }
func (r *NostrRoot) Read(path string, buff []byte, ofst int64, fh uint64) int { func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
node := r.getNode(path) node := r.getNode(path)
if node == nil || node.isDir { if node == nil || node.isDir {
return -fuse.ENOENT return -fuse.ENOENT
@@ -818,7 +818,7 @@ func (r *NostrRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
return n return n
} }
func (r *NostrRoot) Opendir(path string) (int, uint64) { func (r *FSRoot) Opendir(path string) (int, uint64) {
node := r.getNode(path) node := r.getNode(path)
if node == nil { if node == nil {
return -fuse.ENOENT, ^uint64(0) return -fuse.ENOENT, ^uint64(0)
@@ -829,17 +829,17 @@ func (r *NostrRoot) Opendir(path string) (int, uint64) {
return 0, node.ino return 0, node.ino
} }
func (r *NostrRoot) Release(path string, fh uint64) int { func (r *FSRoot) Release(path string, fh uint64) int {
return 0 return 0
} }
func (r *NostrRoot) Releasedir(path string, fh uint64) int { func (r *FSRoot) Releasedir(path string, fh uint64) int {
return 0 return 0
} }
// Create creates a new file // Create creates a new file
func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) { func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) {
// Parse path // parse path
path = strings.ReplaceAll(path, "\\", "/") path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") { if !strings.HasPrefix(path, "/") {
path = "/" + path path = "/" + path
@@ -851,19 +851,19 @@ func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
// Check if parent directory exists // check if parent directory exists
parent, ok := r.nodes[dir] parent, ok := r.nodes[dir]
if !ok || !parent.isDir { if !ok || !parent.isDir {
return -fuse.ENOENT, ^uint64(0) return -fuse.ENOENT, ^uint64(0)
} }
// Check if file already exists // check if file already exists
if _, exists := r.nodes[path]; exists { if _, exists := r.nodes[path]; exists {
return -fuse.EEXIST, ^uint64(0) return -fuse.EEXIST, ^uint64(0)
} }
// Create new file node // create new file node
fileNode := &Node{ fileNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: path, path: path,
name: name, name: name,
@@ -882,7 +882,7 @@ func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) {
} }
// Truncate truncates a file // Truncate truncates a file
func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int { func (r *FSRoot) Truncate(path string, size int64, fh uint64) int {
node := r.getNode(path) node := r.getNode(path)
if node == nil { if node == nil {
return -fuse.ENOENT return -fuse.ENOENT
@@ -899,7 +899,7 @@ func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int {
} else if size < int64(len(node.data)) { } else if size < int64(len(node.data)) {
node.data = node.data[:size] node.data = node.data[:size]
} else { } else {
// Extend with zeros // extend with zeros
newData := make([]byte, size) newData := make([]byte, size)
copy(newData, node.data) copy(newData, node.data)
node.data = newData node.data = newData
@@ -911,7 +911,7 @@ func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int {
} }
// Write writes data to a file // Write writes data to a file
func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int { func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
node := r.getNode(path) node := r.getNode(path)
if node == nil { if node == nil {
return -fuse.ENOENT return -fuse.ENOENT
@@ -925,7 +925,7 @@ func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
endofst := ofst + int64(len(buff)) endofst := ofst + int64(len(buff))
// Extend data if necessary // extend data if necessary
if endofst > int64(len(node.data)) { if endofst > int64(len(node.data)) {
newData := make([]byte, endofst) newData := make([]byte, endofst)
copy(newData, node.data) copy(newData, node.data)
@@ -936,14 +936,14 @@ func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
node.size = int64(len(node.data)) node.size = int64(len(node.data))
node.mtime = time.Now() node.mtime = time.Now()
// Check if this is a note that should be auto-published // check if this is a note that should be auto-published
if r.signer != nil && strings.Contains(path, "/notes/") && !strings.HasPrefix(filepath.Base(path), ".") { if r.signer != nil && strings.Contains(path, "/notes/") && !strings.HasPrefix(filepath.Base(path), ".") {
// Cancel existing timer if any // cancel existing timer if any
if timer, exists := r.pendingNotes[path]; exists { if timer, exists := r.pendingNotes[path]; exists {
timer.Stop() timer.Stop()
} }
// Schedule auto-publish // schedule auto-publish
timeout := r.opts.AutoPublishNotesTimeout timeout := r.opts.AutoPublishNotesTimeout
if timeout > 0 && timeout < time.Hour*24*365 { if timeout > 0 && timeout < time.Hour*24*365 {
r.pendingNotes[path] = time.AfterFunc(timeout, func() { r.pendingNotes[path] = time.AfterFunc(timeout, func() {
@@ -955,7 +955,7 @@ func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
return n return n
} }
func (r *NostrRoot) publishNote(path string) { func (r *FSRoot) publishNote(path string) {
r.mu.Lock() r.mu.Lock()
node, ok := r.nodes[path] node, ok := r.nodes[path]
if !ok { if !ok {
@@ -973,7 +973,7 @@ func (r *NostrRoot) publishNote(path string) {
log := r.getLog() log := r.getLog()
log("- auto-publishing note from %s\n", path) log("- auto-publishing note from %s\n", path)
// Create and sign event // create and sign event
evt := &nostr.Event{ evt := &nostr.Event{
CreatedAt: nostr.Now(), CreatedAt: nostr.Now(),
Kind: 1, Kind: 1,
@@ -986,7 +986,7 @@ func (r *NostrRoot) publishNote(path string) {
return return
} }
// Publish to relays // publish to relays
ctx, cancel := context.WithTimeout(r.ctx, time.Second*10) ctx, cancel := context.WithTimeout(r.ctx, time.Second*10)
defer cancel() defer cancel()
@@ -1005,7 +1005,7 @@ func (r *NostrRoot) publishNote(path string) {
log("- published note %s to %d relays\n", evt.ID.Hex()[:8], len(relays)) log("- published note %s to %d relays\n", evt.ID.Hex()[:8], len(relays))
// Update filename to include event ID // update filename to include event ID
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -1015,7 +1015,7 @@ func (r *NostrRoot) publishNote(path string) {
newName := evt.ID.Hex()[:8] + ext newName := evt.ID.Hex()[:8] + ext
newPath := dir + "/" + newName newPath := dir + "/" + newName
// Rename node // rename node
if _, exists := r.nodes[newPath]; !exists { if _, exists := r.nodes[newPath]; !exists {
node.path = newPath node.path = newPath
node.name = newName node.name = newName
@@ -1032,7 +1032,7 @@ func (r *NostrRoot) publishNote(path string) {
} }
// Unlink deletes a file // Unlink deletes a file
func (r *NostrRoot) Unlink(path string) int { func (r *FSRoot) Unlink(path string) int {
path = strings.ReplaceAll(path, "\\", "/") path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") { if !strings.HasPrefix(path, "/") {
path = "/" + path path = "/" + path
@@ -1044,7 +1044,7 @@ func (r *NostrRoot) Unlink(path string) int {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
// Check if file exists // check if file exists
node, ok := r.nodes[path] node, ok := r.nodes[path]
if !ok { if !ok {
return -fuse.ENOENT return -fuse.ENOENT
@@ -1053,19 +1053,19 @@ func (r *NostrRoot) Unlink(path string) int {
return -fuse.EISDIR return -fuse.EISDIR
} }
// Remove from parent // remove from parent
if parent, ok := r.nodes[dir]; ok { if parent, ok := r.nodes[dir]; ok {
delete(parent.children, name) delete(parent.children, name)
} }
// Remove from nodes map // remove from nodes map
delete(r.nodes, path) delete(r.nodes, path)
return 0 return 0
} }
// Mkdir creates a new directory // Mkdir creates a new directory
func (r *NostrRoot) Mkdir(path string, mode uint32) int { func (r *FSRoot) Mkdir(path string, mode uint32) int {
path = strings.ReplaceAll(path, "\\", "/") path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") { if !strings.HasPrefix(path, "/") {
path = "/" + path path = "/" + path
@@ -1077,26 +1077,26 @@ func (r *NostrRoot) Mkdir(path string, mode uint32) int {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
// Check if parent directory exists // check if parent directory exists
parent, ok := r.nodes[dir] parent, ok := r.nodes[dir]
if !ok || !parent.isDir { if !ok || !parent.isDir {
return -fuse.ENOENT return -fuse.ENOENT
} }
// Check if directory already exists // check if directory already exists
if _, exists := r.nodes[path]; exists { if _, exists := r.nodes[path]; exists {
return -fuse.EEXIST return -fuse.EEXIST
} }
// Create new directory node // create new directory node
dirNode := &Node{ dirNode := &FSNode{
ino: r.nextIno, ino: r.nextIno,
path: path, path: path,
name: name, name: name,
isDir: true, isDir: true,
mode: fuse.S_IFDIR | 0755, mode: fuse.S_IFDIR | 0755,
mtime: time.Now(), mtime: time.Now(),
children: make(map[string]*Node), children: make(map[string]*FSNode),
} }
r.nextIno++ r.nextIno++
@@ -1107,7 +1107,7 @@ func (r *NostrRoot) Mkdir(path string, mode uint32) int {
} }
// Rmdir removes a directory // Rmdir removes a directory
func (r *NostrRoot) Rmdir(path string) int { func (r *FSRoot) Rmdir(path string) int {
path = strings.ReplaceAll(path, "\\", "/") path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") { if !strings.HasPrefix(path, "/") {
path = "/" + path path = "/" + path
@@ -1123,7 +1123,7 @@ func (r *NostrRoot) Rmdir(path string) int {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
// Check if directory exists // check if directory exists
node, ok := r.nodes[path] node, ok := r.nodes[path]
if !ok { if !ok {
return -fuse.ENOENT return -fuse.ENOENT
@@ -1132,24 +1132,24 @@ func (r *NostrRoot) Rmdir(path string) int {
return -fuse.ENOTDIR return -fuse.ENOTDIR
} }
// Check if directory is empty // check if directory is empty
if len(node.children) > 0 { if len(node.children) > 0 {
return -fuse.ENOTEMPTY return -fuse.ENOTEMPTY
} }
// Remove from parent // remove from parent
if parent, ok := r.nodes[dir]; ok { if parent, ok := r.nodes[dir]; ok {
delete(parent.children, name) delete(parent.children, name)
} }
// Remove from nodes map // remove from nodes map
delete(r.nodes, path) delete(r.nodes, path)
return 0 return 0
} }
// Utimens updates file timestamps // Utimens updates file timestamps
func (r *NostrRoot) Utimens(path string, tmsp []fuse.Timespec) int { func (r *FSRoot) Utimens(path string, tmsp []fuse.Timespec) int {
node := r.getNode(path) node := r.getNode(path)
if node == nil { if node == nil {
return -fuse.ENOENT return -fuse.ENOENT
@@ -1164,3 +1164,14 @@ func (r *NostrRoot) Utimens(path string, tmsp []fuse.Timespec) int {
return 0 return 0
} }
func kindToExtension(kind nostr.Kind) string {
switch kind {
case 30023:
return "md"
case 30818:
return "djot"
default:
return "txt"
}
}

View File

@@ -12,7 +12,6 @@ import (
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer" "fiatjaf.com/nostr/keyer"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/fiatjaf/nak/nostrfs"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse" "github.com/winfsp/cgofuse/fuse"
) )
@@ -62,7 +61,7 @@ var fsCmd = &cli.Command{
apat = time.Hour * 24 * 365 * 3 apat = time.Hour * 24 * 365 * 3
} }
root := nostrfs.NewNostrRoot( root := NewFSRoot(
context.WithValue( context.WithValue(
context.WithValue( context.WithValue(
ctx, ctx,
@@ -73,7 +72,7 @@ var fsCmd = &cli.Command{
sys, sys,
kr, kr,
mountpoint, mountpoint,
nostrfs.Options{ FSOptions{
AutoPublishNotesTimeout: apnt, AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat, AutoPublishArticlesTimeout: apat,
}, },
@@ -82,37 +81,37 @@ var fsCmd = &cli.Command{
// create the server // create the server
log("- mounting at %s... ", color.HiCyanString(mountpoint)) log("- mounting at %s... ", color.HiCyanString(mountpoint))
// Create cgofuse host // create cgofuse host
host := fuse.NewFileSystemHost(root) host := fuse.NewFileSystemHost(root)
host.SetCapReaddirPlus(true) host.SetCapReaddirPlus(true)
host.SetUseIno(true) host.SetUseIno(true)
// Mount the filesystem - Windows/WinFsp version // mount the filesystem - Windows/WinFsp version
// Based on rclone cmount implementation // based on rclone cmount implementation
mountArgs := []string{ mountArgs := []string{
"-o", "uid=-1", "-o", "uid=-1",
"-o", "gid=-1", "-o", "gid=-1",
"--FileSystemName=nak", "--FileSystemName=nak",
} }
// Check if mountpoint is a drive letter or directory // check if mountpoint is a drive letter or directory
isDriveLetter := len(mountpoint) == 2 && mountpoint[1] == ':' isDriveLetter := len(mountpoint) == 2 && mountpoint[1] == ':'
if !isDriveLetter { if !isDriveLetter {
// WinFsp primarily supports drive letters on Windows // winFsp primarily supports drive letters on Windows
// Directory mounting may not work reliably // directory mounting may not work reliably
log("WARNING: directory mounting may not work on Windows (WinFsp limitation)\n") log("WARNING: directory mounting may not work on Windows (WinFsp limitation)\n")
log(" consider using a drive letter instead (e.g., 'nak fs Z:')\n") log(" consider using a drive letter instead (e.g., 'nak fs Z:')\n")
// For directory mounts, follow rclone's approach: // for directory mounts, follow rclone's approach:
// 1. Check that mountpoint doesn't already exist // 1. check that mountpoint doesn't already exist
if _, err := os.Stat(mountpoint); err == nil { if _, err := os.Stat(mountpoint); err == nil {
return fmt.Errorf("mountpoint path already exists: %s (must not exist before mounting)", mountpoint) return fmt.Errorf("mountpoint path already exists: %s (must not exist before mounting)", mountpoint)
} else if !os.IsNotExist(err) { } else if !os.IsNotExist(err) {
return fmt.Errorf("failed to check mountpoint: %w", err) return fmt.Errorf("failed to check mountpoint: %w", err)
} }
// 2. Check that parent directory exists // 2. check that parent directory exists
parent := filepath.Join(mountpoint, "..") parent := filepath.Join(mountpoint, "..")
if _, err := os.Stat(parent); err != nil { if _, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -121,7 +120,7 @@ var fsCmd = &cli.Command{
return fmt.Errorf("failed to check parent directory: %w", err) return fmt.Errorf("failed to check parent directory: %w", err)
} }
// 3. Use network mode for directory mounts // 3. use network mode for directory mounts
mountArgs = append(mountArgs, "--VolumePrefix=\\nak\\"+filepath.Base(mountpoint)) mountArgs = append(mountArgs, "--VolumePrefix=\\nak\\"+filepath.Base(mountpoint))
} }
@@ -132,7 +131,6 @@ var fsCmd = &cli.Command{
log("ok.\n") log("ok.\n")
// Mount in main thread like hellofs
if !host.Mount("", mountArgs) { if !host.Mount("", mountArgs) {
return fmt.Errorf("failed to mount filesystem") return fmt.Errorf("failed to mount filesystem")
} }

View File

@@ -46,8 +46,14 @@ var (
) )
func isPiped() bool { func isPiped() bool {
stat, _ := os.Stdin.Stat() stat, err := os.Stdin.Stat()
return stat.Mode()&os.ModeCharDevice == 0 if err != nil {
panic(err)
}
mode := stat.Mode()
is := mode&os.ModeCharDevice == 0
return is
} }
func getJsonsOrBlank() iter.Seq[string] { func getJsonsOrBlank() iter.Seq[string] {
@@ -76,7 +82,7 @@ func getJsonsOrBlank() iter.Seq[string] {
return true return true
}) })
if !hasStdin && !isPiped() { if !hasStdin {
yield("{}") yield("{}")
} }

View File

@@ -1,16 +0,0 @@
package nostrfs
import (
"fiatjaf.com/nostr"
)
func kindToExtension(kind nostr.Kind) string {
switch kind {
case 30023:
return "md"
case 30818:
return "adoc"
default:
return "txt"
}
}