1// Package relayapp provides the storage-backed base relay used by the2// standalone tinyrelay command. It deliberately contains no hosted3// application features; those remain in daemon.4package relayapp56import (7 "context"8 "errors"9 "fmt"10 "os"11 "path/filepath"12 "sort"13 "strings"14 "sync"15 "syscall"16 "time"1718 "github.com/FelineStateMachine/tinyrelay/internal/event"19 "github.com/FelineStateMachine/tinyrelay/internal/relay"20 "github.com/FelineStateMachine/tinyrelay/internal/storage"21 "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"22)2324// Config controls one standalone relay database and its public metadata.25type Config struct {26 DataDir string27 PublicURL string28 Name string29 Description string30 Contact string31 Owner string32 AuthRequired bool33 MaxMessageBytes int6434 MaxPendingBytes int35}3637// Backend is the storage-backed relay implementation. It can be passed to38// relay.New and is safe to use until Close returns.39type Backend struct {40 store *storage.Store41 lock *os.File42 cfg Config43 closeOnce sync.Once44 closeErr error45}4647// Open creates or opens a standalone relay database below Config.DataDir.48func Open(ctx context.Context, cfg Config) (*Backend, error) {49 if strings.TrimSpace(cfg.DataDir) == "" {50 return nil, errors.New("relayapp: data directory is required")51 }52 dataDir, err := filepath.Abs(cfg.DataDir)53 if err != nil {54 return nil, fmt.Errorf("relayapp: resolve data directory: %w", err)55 }56 cfg.DataDir = dataDir57 if err := os.MkdirAll(dataDir, 0700); err != nil {58 return nil, fmt.Errorf("relayapp: create data directory: %w", err)59 }60 lock, err := os.OpenFile(filepath.Join(dataDir, ".tinyrelay.lock"), os.O_CREATE|os.O_RDWR, 0600)61 if err != nil {62 return nil, fmt.Errorf("relayapp: open data lock: %w", err)63 }64 if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {65 if errors.Is(err, syscall.EWOULDBLOCK) {66 return nil, errors.Join(errors.New("relayapp: data directory is already in use"), lock.Close())67 }68 return nil, errors.Join(fmt.Errorf("relayapp: lock data directory: %w", err), lock.Close())69 }70 store, err := storage.Open(ctx, filepath.Join(dataDir, "events.db"))71 if err != nil {72 return nil, errors.Join(err, syscall.Flock(int(lock.Fd()), syscall.LOCK_UN), lock.Close())73 }74 return &Backend{store: store, lock: lock, cfg: cfg}, nil75}7677// Ready verifies that the database is available.78func (b *Backend) Ready(ctx context.Context) error {79 if b == nil || b.store == nil {80 return errors.New("relayapp: backend is closed")81 }82 return b.store.DB().PingContext(ctx)83}8485// Close releases the database.86func (b *Backend) Close() error {87 if b == nil || b.store == nil {88 return nil89 }90 b.closeOnce.Do(func() {91 storeErr := b.store.Close()92 unlockErr := syscall.Flock(int(b.lock.Fd()), syscall.LOCK_UN)93 closeLockErr := b.lock.Close()94 b.closeErr = errors.Join(storeErr, unlockErr, closeLockErr)95 })96 return b.closeErr97}9899func (b *Backend) Publish(ctx context.Context, e event.Event, s relay.Session) (string, error) {100 if b.store == nil {101 return "", errors.New("relayapp: backend is closed")102 }103 now := time.Now().Unix()104 if b.cfg.AuthRequired && !contains(s.PubKeys, e.PubKey) {105 return "", errors.New("auth-required: publishing requires AUTH")106 }107 if hasTag(e, "-") && !contains(s.PubKeys, e.PubKey) {108 return "", errors.New("auth-required: this event may only be published by its author")109 }110 if e.Kind == event.KIND_AUTH {111 return "", errors.New("blocked: AUTH events use AUTH")112 }113 if err := event.Validate(e); err != nil {114 return "", fmt.Errorf("invalid: %w", err)115 }116 if e.CreatedAt > now+900 {117 return "", errors.New("invalid: event creation date is too far off from the current time")118 }119 if expiration := event.Expiration(e); expiration > 0 && expiration <= now {120 return "", errors.New("invalid: event has already expired")121 }122 _, err := b.store.Save(ctx, e, storage.SaveOptions{Now: now})123 if errors.Is(err, storage.ErrDuplicate) {124 return storage.ErrDuplicate.Error(), nil125 }126 return "", err127}128129func (b *Backend) Query(ctx context.Context, filters []event.Filter, s relay.Session) ([]event.Event, error) {130 if b.store == nil {131 return nil, errors.New("relayapp: backend is closed")132 }133 if b.cfg.AuthRequired && len(s.PubKeys) == 0 {134 return nil, errors.New("auth-required: this relay requires AUTH")135 }136 seen := make(map[string]struct{})137 var result []event.Event138 for _, f := range filters {139 row, err := b.store.Query(ctx, f, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true, IncludeCallbackRegistrations: true}})140 if err != nil {141 return nil, err142 }143 for _, e := range row.Events {144 if _, ok := seen[e.ID]; ok {145 continue146 }147 seen[e.ID] = struct{}{}148 result = append(result, e)149 }150 }151 sort.Slice(result, func(i, j int) bool {152 if result[i].CreatedAt == result[j].CreatedAt {153 return result[i].ID < result[j].ID154 }155 return result[i].CreatedAt > result[j].CreatedAt156 })157 return result, nil158}159160func (b *Backend) Count(ctx context.Context, filters []event.Filter, s relay.Session) (any, error) {161 copyFilters := append([]event.Filter(nil), filters...)162 for i := range copyFilters {163 copyFilters[i].Limit = nil164 }165 events, err := b.Query(ctx, copyFilters, s)166 if err != nil {167 return nil, err168 }169 return map[string]any{"count": len(events)}, nil170}171172func (b *Backend) Sync(ctx context.Context, f event.Filter, s relay.Session) ([]syncprotocol.Item, error) {173 f.Limit = nil174 events, err := b.Query(ctx, []event.Filter{f}, s)175 if err != nil {176 return nil, err177 }178 items := make([]syncprotocol.Item, len(events))179 for i, e := range events {180 items[i] = syncprotocol.Item{ID: e.ID, Timestamp: e.CreatedAt}181 }182 return items, nil183}184185// CanRead applies current policy to a live event.186func (b *Backend) CanRead(e event.Event, s relay.Session) bool {187 return (!b.cfg.AuthRequired || len(s.PubKeys) > 0) && visible(e)188}189190// CanReadFilter applies current policy with subscription filter context.191func (b *Backend) CanReadFilter(e event.Event, s relay.Session, f *event.Filter) bool {192 return b.CanRead(e, s)193}194195func visible(e event.Event) bool {196 expires := event.Expiration(e)197 return expires == 0 || expires > time.Now().Unix()198}199200func hasTag(e event.Event, name string) bool {201 for _, tag := range e.Tags {202 if len(tag) > 0 && tag[0] == name {203 return true204 }205 }206 return false207}208209// Information returns conservative NIP-11 metadata for this relay.210func (b *Backend) Information() map[string]any {211 info := map[string]any{"name": b.cfg.Name, "description": b.cfg.Description, "supported_nips": []int{1, 11, 42, 45, 77}, "software": "https://github.com/FelineStateMachine/tinyrelay"}212 limitation := map[string]any{"auth_required": b.cfg.AuthRequired}213 if b.cfg.MaxMessageBytes > 0 {214 limitation["max_message_length"] = b.cfg.MaxMessageBytes215 }216 info["limitation"] = limitation217 if b.cfg.Contact != "" {218 info["contact"] = b.cfg.Contact219 }220 if b.cfg.Owner != "" {221 info["pubkey"] = b.cfg.Owner222 }223 return info224}225226func contains(values []string, value string) bool {227 for _, v := range values {228 if v == value {229 return true230 }231 }232 return false233}