diff options
44 files changed, 10905 insertions, 11841 deletions
@@ -1,16 +1,13 @@ -/src/version.go -/*.bin -/*.db* -/src/*.a +/doc/* +!/doc/*.en.*.adoc +/doc/*.en.3.adoc +/po/*/*.mo +/src/*/ +/src/*.class +/tests/*.class +/tests/fuzz/*.class +/*.jar /src/*.bin -/src/*cgo* -/tests/*.a -/tests/*.bin -/tests/functional/*/*.a -/tests/functional/*/*.bin -/tests/fuzz/*/*.a -/tests/fuzz/*/*.bin -/tests/benchmarks/*/*.a -/tests/benchmarks/*/*.bin -/tests/benchmarks/*/*.txt -/tests/fuzz/corpus/ +/src/cli.edn +/schema.edn +/datomic.log @@ -3,36 +3,53 @@ DATE = 1970-01-01 VERSION = 0.1.0 NAME = papod NAME_UC = $(NAME) -LANGUAGES = en ## Installation prefix. Defaults to "/usr". PREFIX = /usr BINDIR = $(PREFIX)/bin LIBDIR = $(PREFIX)/lib -GOLIBDIR = $(LIBDIR)/go INCLUDEDIR = $(PREFIX)/include SRCDIR = $(PREFIX)/src/$(NAME) SHAREDIR = $(PREFIX)/share LOCALEDIR = $(SHAREDIR)/locale MANDIR = $(SHAREDIR)/man +JAVADIR = $(SHAREDIR)/java EXEC = ./ ## Where to store the installation. Empty by default. DESTDIR = -LDLIBS = --static -lscrypt-kdf -lsqlite3 -lm -GOCFLAGS = -I $(GOLIBDIR) -GOLDFLAGS = -L $(GOLIBDIR) +LDLIBS = +JAVA = java -client +CLASSPATH = `printf '$(JARPATH)/%s.jar\n' $(JARDEPS) | paste -sd:` +JARDEPS = \ + cracha \ + fiinha \ + jasm \ + labareda \ + peer \ + dtmc \ + base \ + clojure .SUFFIXES: -.SUFFIXES: .go .a .bin .bin-check +.SUFFIXES: .in .clj .class .class-check .jar .jar-check .adoc .po .mo +.SUFFIXES: .sh .sh-check -.go.a: - go tool compile -I $(@D) $(GOCFLAGS) -o $@ -p $(*F) \ - `find $< $$(if [ $(*F) != main ]; then \ - echo src/$(NAME).go src/version.go; fi) | uniq` +.in: + sed \ + -e 's:@VERSION@:$(VERSION):g' \ + -e 's:@DATE@:$(DATE):g' \ + -e 's:@NAME@:$(NAME):g' \ + -e 's:@JAVADIR@:$(JAVADIR):g' \ + -e "s_@CLASSPATH@_$(CLASSPATH)_g" \ + < $< > $@ + if [ -x $< ]; then chmod +x $@; fi -.a.bin: - go tool link -L $(@D) $(GOLDFLAGS) -o $@ --extldflags '$(LDLIBS)' $< +.adoc: + asciidoctor -b manpage -o $@ $< + +.po.mo: + msgfmt -cfv -o $@ $< @@ -40,33 +57,46 @@ all: include deps.mk -libs.a = $(libs.go:.go=.a) -mains.a = $(mains.go:.go=.a) -mains.bin = $(mains.go:.go=.bin) -functional-tests/lib.a = $(functional-tests/lib.go:.go=.a) -fuzz-targets/lib.a = $(fuzz-targets/lib.go:.go=.a) -benchmarks/lib.a = $(benchmarks/lib.go:.go=.a) +integration.sh-check = $(integration.sh:.sh=.sh-check) +fuzz.class-check = $(fuzz.clj:.clj=.class-check) + +inits = \ + src/$(NAME)__init.class \ + tests/unit__init.class \ + tests/integration__init.class \ + $(fuzzinit.class) \ + +manpages.N.adoc = $(manpages.en.N.adoc) $(manpages.XX.N.adoc) +manpages.N = $(manpages.N.adoc:.adoc=) doc/$(NAME).en.3 +sources.mo = $(sources.po:.po=.mo) sources = \ - src/$(NAME).go \ - src/version.go \ - src/main.go \ + src/$(NAME).bin.in \ + src/$(NAME).clj \ + src/labareda.edn \ derived-assets = \ - src/version.go \ - $(libs.a) \ - $(mains.a) \ - $(mains.bin) \ - $(NAME).bin \ + $(inits) \ + $(NAME).jar \ + unit.jar \ + integration.jar \ + src/$(NAME).bin \ + schema.edn \ + src/cli.edn \ + $(manpages.XX.N.adoc) \ + $(manpages.N) \ + $(sources.mo) \ + doc/$(NAME).en.3.adoc \ side-assets = \ - *$(NAME).db* \ - tests/functional/*/*.go.db* \ - tests/fuzz/corpus/ \ - tests/benchmarks/*/main.txt \ - $(NAME).daemon.sock \ - $(NAME).commander.sock \ + src/$(NAME)/ \ + src/*.class \ + tests/*.class \ + tests/fuzz/*.class \ + datomic.log \ + $(NAME).daemon.socket \ + $(NAME).commander.socket \ @@ -75,74 +105,89 @@ side-assets = \ all: $(derived-assets) -$(libs.a): Makefile deps.mk -$(libs.a): src/$(NAME).go src/version.go +$(inits): Makefile deps.mk + +$(integration.sh): $(NAME).jar +$(fuzzinit.class): $(NAME).jar + + +src/$(NAME)__init.class: src/$(NAME).clj +tests/unit__init.class: tests/unit.clj $(NAME).jar +tests/integration__init.class: tests/integration.clj $(NAME).jar +$(inits): + rm -f $@ + JAR=`if [ $(@D) != 'src' ]; then echo $(NAME).jar:; fi`; \ + DIR=`echo $@ | cut -d/ -f1`; \ + NS=`echo $@ | cut -d/ -f2- | sed 's/__.*$$//' | tr / .`; \ + $(JAVA) -ea --class-path $$DIR:$$JAR$(CLASSPATH) clojure.main -e \ + "(binding [*compile-path* \"$$DIR\"] \ + (compile '$$NS))" + +src/cli.edn: src/labareda.edn + labareda compile < src/labareda.edn > $@ + +$(NAME).jar: src/$(NAME)__init.class src/cli.edn + cd src && jar -cvf ../$@ `find . -name '*.class'` cli.edn +unit.jar: tests/unit__init.class +integration.jar: tests/integration__init.class +unit.jar integration.jar: + cd tests && jar -cvf ../$@ $**.class -$(fuzz-targets/lib.a): - go tool compile $(GOCFLAGS) -o $@ -p $(NAME) -d=libfuzzer \ - $*.go src/$(NAME).go src/version.go +schema.edn: $(NAME).jar + $(JAVA) -ea --class-path $(NAME).jar:$(CLASSPATH) clojure.main -e \ + "(do (require '$(NAME) 'clojure.pprint) \ + (clojure.pprint/pprint @#'$(NAME)/schema))" > $@ -src/version.go: Makefile - echo 'package $(NAME); const Version = "$(VERSION)"' > $@ +$(manpages.XX.N.adoc): po/doc/po4a.cfg + po4a --no-update --translate-only $@ po/doc/po4a.cfg -$(NAME).bin: src/main.bin - ln -fs src/main.bin $@ +doc/$(NAME).en.3.adoc: $(NAME).jar + env CLASSPATH="$(CLASSPATH)" base $(NAME).jar > $@ -.PRECIOUS: tests/queries.sql -tests/queries.sql: tests/main.bin ALWAYS - env TESTING_DUMP_SQL_QUERIES=1 $(EXEC)tests/main.bin | diff -U10 $@ - +src/schema.edn-check: schema.edn + diff -U10 src/schema.edn schema.edn -tests.bin-check = \ - tests/main.bin-check \ - $(functional-tests/main.go:.go=.bin-check) \ +unit.jar-check: unit.jar +integration.jar-check: integration.jar +unit.jar-check integration.jar-check: + $(JAVA) -ea --class-path $*.jar:$(NAME).jar:$(CLASSPATH) $* -$(tests.bin-check): - $(EXEC)$*.bin -check-unit: $(tests.bin-check) -check-unit: tests/queries.sql +check-unit: unit.jar-check +check-unit: src/schema.edn-check -integration-tests = \ - tests/cli-opts.sh \ - tests/integration.sh \ +$(integration.sh-check): + env CLASSPATH="$(CLASSPATH)" \ + sh $*.sh -.PRECIOUS: $(integration-tests) -$(integration-tests): $(NAME).bin -$(integration-tests): ALWAYS - sh $@ -check-integration: $(integration-tests) +check-integration: integration.jar-check +check-integration: $(integration.sh-check) check-integration: fuzz ## Run all tests. Each test suite is isolated, so that a parallel ## build can run tests at the same time. The required artifacts ## are created if missing. -check: check-unit check-integration - +check-acceptance: papod.jar + sh tests/acceptance.sh \ + --tb=short -q -FUZZSEC=1 -fuzz-targets/main.bin-check = $(fuzz-targets/main.go:.go=.bin-check) -$(fuzz-targets/main.bin-check): - $(EXEC)$*.bin --test.fuzztime=$(FUZZSEC)s \ - --test.fuzz='.*' --test.fuzzcachedir=tests/fuzz/corpus - -fuzz: $(fuzz-targets/main.bin-check) +check: check-unit check-integration -benchmarks/main.bin-check = $(benchmarks/main.go:.go=.bin-check) -$(benchmarks/main.bin-check): - printf '%s\n' '$(EXEC)$*.bin' > $*.txt - LANG=POSIX.UTF-8 time -p $(EXEC)$*.bin 2>> $*.txt - printf '%s\n' '$*.txt' +FUZZSEC=1 +$(fuzz.class-check): + $(JAVA) -ea --class-path tests:$(NAME).jar:$(CLASSPATH) \ + `echo $* | cut -d/ -f2- | tr / .` $(FUZZSEC) -bench: $(benchmarks/main.bin-check) +fuzz: $(fuzz.class-check) @@ -156,28 +201,33 @@ clean: ## ensures that all installable artifacts are crafted beforehand. install: all mkdir -p \ - '$(DESTDIR)$(BINDIR)' \ - '$(DESTDIR)$(GOLIBDIR)' \ - '$(DESTDIR)$(SRCDIR)' \ + '$(DESTDIR)$(BINDIR)' \ + '$(DESTDIR)$(JAVADIR)' \ + '$(DESTDIR)$(SRCDIR)' \ - cp $(NAME).bin '$(DESTDIR)$(BINDIR)'/$(NAME) - cp src/$(NAME).a '$(DESTDIR)$(GOLIBDIR)' + cp src/$(NAME).bin '$(DESTDIR)$(BINDIR)'/$(NAME) + cp $(NAME).jar '$(DESTDIR)$(JAVADIR)' cp $(sources) '$(DESTDIR)$(SRCDIR)' + instool '$(DESTDIR)$(MANDIR)' install man $(manpages.N) + instool '$(DESTDIR)$(LOCALEDIR)' install mo $(sources.mo) ## Uninstalls from $(DESTDIR)$(PREFIX). This is a perfect mirror ## of the "install" target, and removes *all* that was installed. ## A dedicated test asserts that this is always true. uninstall: rm -rf \ - '$(DESTDIR)$(BINDIR)'/$(NAME) \ - '$(DESTDIR)$(GOLIBDIR)'/$(NAME).a \ - '$(DESTDIR)$(SRCDIR)' \ + '$(DESTDIR)$(BINDIR)'/$(NAME) \ + '$(DESTDIR)$(JAVADIR)'/$(NAME).jar \ + '$(DESTDIR)$(SRCDIR)' \ + + instool '$(DESTDIR)$(MANDIR)' uninstall man $(manpages.N) + instool '$(DESTDIR)$(LOCALEDIR)' uninstall mo $(sources.mo) ## Run it locally. run: all - rm -f $(NAME).daemon.sock $(NAME).commander.sock - ./$(NAME).bin + java + $(JAVA) -ea --class-path $(NAME).jar:$(CLASSPATH) papod ALWAYS: @@ -1,57 +1,15 @@ -libs.go = \ - src/papod.go \ - tests/benchmarks/join-leave/papod.go \ - tests/functional/multiple-channels-and-users/papod.go \ - tests/fuzz/api-check/papod.go \ - tests/papod.go \ +manpages.en.N.adoc = \ + doc/papod.en.0.adoc \ -mains.go = \ - src/main.go \ - tests/benchmarks/join-leave/main.go \ - tests/functional/multiple-channels-and-users/main.go \ - tests/fuzz/api-check/main.go \ - tests/main.go \ +integration.sh = \ + tests/integration.sh \ -functional-tests/lib.go = \ - tests/functional/multiple-channels-and-users/papod.go \ +fuzz.clj = \ + tests/fuzz/api.clj \ -functional-tests/main.go = \ - tests/functional/multiple-channels-and-users/main.go \ +fuzzinit.class = \ + tests/fuzz/api__init.class \ -fuzz-targets/lib.go = \ - tests/fuzz/api-check/papod.go \ - -fuzz-targets/main.go = \ - tests/fuzz/api-check/main.go \ - -benchmarks/lib.go = \ - tests/benchmarks/join-leave/papod.go \ - -benchmarks/main.go = \ - tests/benchmarks/join-leave/main.go \ - -src/main.a: src/main.go -src/papod.a: src/papod.go -tests/benchmarks/join-leave/main.a: tests/benchmarks/join-leave/main.go -tests/benchmarks/join-leave/papod.a: tests/benchmarks/join-leave/papod.go -tests/functional/multiple-channels-and-users/main.a: tests/functional/multiple-channels-and-users/main.go -tests/functional/multiple-channels-and-users/papod.a: tests/functional/multiple-channels-and-users/papod.go -tests/fuzz/api-check/main.a: tests/fuzz/api-check/main.go -tests/fuzz/api-check/papod.a: tests/fuzz/api-check/papod.go -tests/main.a: tests/main.go -tests/papod.a: tests/papod.go -src/main.bin: src/main.a -tests/benchmarks/join-leave/main.bin: tests/benchmarks/join-leave/main.a -tests/functional/multiple-channels-and-users/main.bin: tests/functional/multiple-channels-and-users/main.a -tests/fuzz/api-check/main.bin: tests/fuzz/api-check/main.a -tests/main.bin: tests/main.a -src/main.bin-check: src/main.bin -tests/benchmarks/join-leave/main.bin-check: tests/benchmarks/join-leave/main.bin -tests/functional/multiple-channels-and-users/main.bin-check: tests/functional/multiple-channels-and-users/main.bin -tests/fuzz/api-check/main.bin-check: tests/fuzz/api-check/main.bin -tests/main.bin-check: tests/main.bin -src/main.a: src/$(NAME).a -tests/benchmarks/join-leave/main.a: tests/benchmarks/join-leave/$(NAME).a -tests/functional/multiple-channels-and-users/main.a: tests/functional/multiple-channels-and-users/$(NAME).a -tests/fuzz/api-check/main.a: tests/fuzz/api-check/$(NAME).a -tests/main.a: tests/$(NAME).a +tests/integration.sh-check: tests/integration.sh +tests/fuzz/api__init.class: tests/fuzz/api.clj +tests/fuzz/api.class-check: tests/fuzz/api__init.class diff --git a/doc/c10m.adoc b/doc/c10m.adoc new file mode 100644 index 0000000..081d4a8 --- /dev/null +++ b/doc/c10m.adoc @@ -0,0 +1,117 @@ + = C10M: 10 Million Concurrent Connections + :author: papod + :revdate: 2026-04-24 + + == Goal + + Cap a single papod process at 10 million concurrent connections across + multiple networks. This is a C10M target. + + == Architecture: Virtual Threads + + Each client connection runs in its own Java virtual thread + (`Thread/ofVirtual`). Virtual threads are cheap (~1KB stack) and + multiplexed onto a small pool of carrier threads (default: CPU core + count). + + This works well *if* virtual threads never pin their carrier thread. + + == Pinning Problem + + A virtual thread *pins* its carrier thread when it blocks inside a + `synchronized` block or JNI call. The carrier thread cannot run other + virtual threads while pinned. With ~8–16 carrier threads and millions + of virtual threads, even a small percentage pinning simultaneously + exhausts the carrier pool and stalls the entire server. + + === Datomic + + Datomic Free/Pro was written before Project Loom. Its internals almost + certainly use `synchronized` rather than `java.util.concurrent.locks`. + Every `@(d/transact ...)` and `(d/q ...)` call may pin. + + At IRC-hobby scale (hundreds of connections) this is harmless. At C10M + it is a showstopper. + + === Diagnosis + + Run with `-Djdk.tracePinnedThreads=short` under load to identify + pinning sites. + + == Mitigation Strategies + + === 1. Bounded Datomic thread pool + + + Offload all `d/transact` and `d/q` calls to a fixed-size pool of + platform threads. Virtual threads submit work and `await` via + `CompletableFuture` (no pinning). + + [source,clojure] + ---- + (def ^:private datomic-pool + (java.util.concurrent.Executors/newFixedThreadPool 64)) + + (defn transact-async [conn tx-data] + (let [cf (java.util.concurrent.CompletableFuture.)] + (.submit datomic-pool + (reify Runnable + (run [_] + (try + (.complete cf @(d/transact conn tx-data)) + (catch Exception e + (.completeExceptionally cf e)))))) + cf)) + ---- + + Pros:: Simple, contained. Virtual threads never touch Datomic directly. + Cons:: Adds a queue. Pool size must be tuned (too small = backpressure, + too large = Datomic contention). + + === 2. Use `d/transact-async` natively + + Datomic's `d/transact-async` returns a future without blocking the + caller. Restructure the reply pipeline to be async: `handle-privmsg` + returns a deferred result, `send-replies!` awaits it. + + Pros:: No extra pool. Uses Datomic's own async path. + Cons:: Requires restructuring the entire request→reply pipeline from + synchronous to async. Large change. + + === 3. Replace Datomic Free + + Use a database client that is virtual-thread-safe: + + - XTDB v2 (built on Arrow/Kafka, Java 21 aware) + - Raw JDBC + HikariCP (virtual-thread-friendly since 5.1.0) + - SQLite via JDBC (single-writer, but no `synchronized` in the driver) + + + Pros:: Eliminates the root cause. + Cons:: Migration cost. Loss of Datomic's immutable history model (which + papod relies on for CHATHISTORY, edit history, audit). + + == Recommendation + + Start with *Strategy 1* (bounded thread pool). It is the smallest + change, keeps the existing synchronous handler signatures, and can be + implemented incrementally: + + 1. Wrap `d/transact` and `d/q` calls behind helper functions. + 2. Run those helpers on a fixed platform-thread pool. + 3. Virtual threads `(.get future)` on the result — this blocks the + virtual thread (fine, no pinning) without blocking a carrier thread. + + Revisit if the pool becomes a bottleneck under load testing. + + == Open Questions + + - What is the actual pinning profile of Datomic Free under load? + (Measure before optimizing.) + - Should the persistence layer be swappable (protocol/interface) to + allow future migration? + - Is the in-memory `clients` atom (`atom {}` with 10M entries) + performant enough, or does it need a concurrent map? + - At C10M, `doseq` over channel members for PRIVMSG fan-out is O(n). + Channels with 100K members need a different broadcast strategy. + diff --git a/doc/edit-proposal.md b/doc/edit-proposal.md new file mode 100644 index 0000000..806e674 --- /dev/null +++ b/doc/edit-proposal.md @@ -0,0 +1,201 @@ +--- +title: Message editing +layout: spec +work-in-progress: true +copyrights: + - + name: "Andre Hora" + period: "2026" +--- + +## Notes for implementing work-in-progress version + +This is a work-in-progress specification. + +Software implementing this work-in-progress specification MUST NOT use the +unprefixed `message-editing` capability name. +Instead, implementations SHOULD use the `draft/message-editing` capability +name to be interoperable with other software implementing a compatible +work-in-progress version. + +The final version of the specification will use an unprefixed capability name. + +This proposal is adapted from the direction of +[IRCv3 PR #425](https://github.com/ircv3/ircv3-specifications/pull/425) +and mirrors the structure of [`draft/message-redaction`][]. + +## Introduction + +This specification enables messages to be edited after they are sent. +Use cases include correcting typos, updating information, and amending +accidentally sent content. These are cosmetic use cases and do not provide any +operational security guarantees. The original message content remains visible to +clients that do not support this capability, and servers MAY retain the original +content in history. + +## Architecture + +### Dependencies + +Clients wishing to use this capability MUST negotiate the [`message-tags`][] +capability with the server. +Clients SHOULD negotiate the [`echo-message`][] capability in order to receive +message IDs for their own messages, so they can be edited. + +### Capability + +This specification adds the `draft/message-editing` capability. +Clients MUST ignore this capability's value, if any. + +Implementations that negotiate this capability indicate that they are +capable of handling the command described below. + +### Command + +To edit a message, a client MUST negotiate the `draft/message-editing` +capability and send an `EDIT` command to a target nickname or channel. +The command is defined as follows: + + EDIT <target> <msgid> :<new content> + +Where `<msgid>` is the id of the message to be edited, which MUST be a +`PRIVMSG` or `NOTICE`. + +The `<new content>` parameter is required and contains the full replacement text. +As the last parameter, it MAY contain spaces. + +If the client is authorised to edit the message, the server: + +* SHOULD forward this `EDIT`, with an appropriate prefix, to the target + recipients that have negotiated the `draft/message-editing` capability, in the + same way as PRIVMSG messages. +* MUST NOT forward this `EDIT` to target recipients that have not negotiated + this capability (see "Fallback" below). +* SHOULD assign a new `msgid` to the EDIT event itself, distinct from the + original message's `msgid`. + +### Chat history + +After a message is edited, [`chathistory`][] responses SHOULD include the +`EDIT` message after the original message. The original message's content +SHOULD be preserved as-is, with the `EDIT` indicating the replacement. + +Clients reconstructing a conversation from history SHOULD display only the +most recent edit for each original message. + +### Errors + +This specification defines `FAIL` messages using the [standard replies][] +framework for notifying clients of errors with message editing. +The following codes are defined, with sample plain text descriptions. + +* `FAIL EDIT INVALID_TARGET <target> :You cannot edit messages in <target>` +* `FAIL EDIT EDIT_FORBIDDEN <target> <target-msgid> :You are not authorised to edit this message` +* `FAIL EDIT EDIT_WINDOW_EXPIRED <target> <target-msgid> <window> :You can no longer edit this message` +* `FAIL EDIT UNKNOWN_MSGID <target> <target-msgid> :This message does not exist or is too old` + +## Client implementation considerations + +It is strongly RECOMMENDED that clients provide visible edit history to users. +This helps ensure accountability and mitigates abuse through surreptitious +editing. This could be done via a tooltip showing the original text, an +"(edited)" indicator, or a separate log. + +Clients SHOULD display the edited content as the primary message text, with +an indication that the message was modified. + +For the purposes of user interface, clients MAY assume that their own messages +are editable. However, this will not always be the case. Pending a mechanism +for discovering edit permissions, clients SHOULD allow users to attempt to edit +their own messages via some mechanism. + +When an `EDIT` command's `msgid` parameter references a known message not in +the `target`'s history, clients MUST ignore it. + +## Server implementation considerations + +This section is non-normative. + +Servers SHOULD restrict editing to the original message author by default. +Channel operators or server administrators MAY be granted edit permissions +at the server's discretion, though this is less common than for redaction. + +Servers MAY impose a time window after which edits are no longer permitted. +The `EDIT_WINDOW_EXPIRED` error code is provided for this purpose. + +Servers MAY choose to store the full edit history (all versions of a message) +or only the latest version. Datomic-backed implementations naturally retain +full history via immutable datoms. + +### Message validation + +To implement validation, servers require a mechanism for determining whether +a particular edit action is permitted. The same considerations as +[`draft/message-redaction`][] apply: servers can look up message properties +from the ID, or encode required properties within the message ID itself. + +### Fallback + +Server implementations might choose to inform clients that haven't negotiated +the capability that an edit has taken place. The fallback method used (if any) +is left up to server implementations, but could take the form of a standard +NOTICE or PRIVMSG with information about the action. For example: + + :irc.example.com NOTICE #channel :nickname edited a message from 5 seconds ago + +Alternatively, servers could re-send the message as a new PRIVMSG with an +indication that it replaces a prior message, though this risks confusion +with standard clients. + +## Security considerations + +The ability to edit messages does not offer any information or operational +security guarantees. Once a message has been sent, assume that the original +content will remain visible to any recipients or servers, whether or not it +is subsequently edited. Above all else, clients that do not support this +specification will not see any changes to the original message. + +Servers SHOULD retain the original message content in their history backend, +even after an edit, to support audit and accountability use cases. + +## Examples + +Editing a PRIVMSG: + + C: PRIVMSG #channel :an exmple with a tpyo + S: @msgid=123 :nick!u@h PRIVMSG #channel :an exmple with a tpyo + C: EDIT #channel 123 :an example with no typos + S: @msgid=456 :nick!u@h EDIT #channel 123 :an example with no typos + +Editing a message in a DM: + + C: PRIVMSG friend :meet me at 3pm + S: @msgid=789 :nick!u@h PRIVMSG friend :meet me at 3pm + C: EDIT friend 789 :meet me at 4pm actually + S: @msgid=790 :nick!u@h EDIT friend 789 :meet me at 4pm actually + +Failed edit (not authorised): + + C: EDIT #channel 123 :trying to edit someone else's message + S: FAIL EDIT EDIT_FORBIDDEN #channel 123 :You are not authorised to edit this message + +Failed edit (window expired): + + C: EDIT #channel 123 :too late + S: FAIL EDIT EDIT_WINDOW_EXPIRED #channel 123 300 :You can no longer edit this message + +Interaction with chat history: + + C: CHATHISTORY LATEST #channel * 50 + S: BATCH +abc chathistory #channel + S: @batch=abc;msgid=123;time=2026-04-22T10:00:00.000Z :nick!u@h PRIVMSG #channel :an exmple with a tpyo + S: @batch=abc;msgid=456;time=2026-04-22T10:00:05.000Z :nick!u@h EDIT #channel 123 :an example with no typos + S: BATCH -abc + + +[`echo-message`]: ../extensions/echo-message.html +[standard replies]: ../extensions/standard-replies.html +[`message-tags`]: ../extensions/message-tags.html +[`msgid`]: ../extensions/message-ids.html +[`chathistory`]: ../extensions/chathistory.html +[`draft/message-redaction`]: ../extensions/message-redaction.html diff --git a/doc/papod.en.0.adoc b/doc/papod.en.0.adoc new file mode 100644 index 0000000..253797c --- /dev/null +++ b/doc/papod.en.0.adoc @@ -0,0 +1,5 @@ += papod(0) + +== NAME + +papod - . @@ -8,4 +8,30 @@ gotext } } + :long-description #"- + Papo is team collaboration platform composed of a suite of free and open source + software made for communities and companies. It features chat messaging, file + sharing and virtual workspaces for working online together. + + Papo improves on existing solutions by: + + 1. offering glossy UI for the needs of modern users; + 2. being built on existing stabilished standards, enabling existing workflows + and infrastruture to interoperate, instead of creating yet another ad-hoc + messaging API; + 3. being 100% free-software, down to the server configuration files and its + acompanying guides on how to use, customize or self-host, eliminating all + forms of lock-in; + 4. offering a hosted option at `https://papo.im` for those who whish to pay for + a managed service that is as transparent as possible. + + The software is made as close to zero-config as possible, both to make itself + simpler but also to make it easier for those who wish to self-host. + + Underneath all of this, is just plain old IRC. Not "a compatibility layer to + IRC", just IRC. Most of "modern" functionalities are available via the "modern" + option which implements IRCv3 plus a couple of extensions, and there's also a + "classic" ircd option with traditional RFC 1459 capabilities which serves as a + replacement to other traditional ircd implementations. + "# } @@ -1,40 +1,24 @@ #!/bin/sh -set -eu +set -euo pipefail export LANG=POSIX.UTF-8 -no_main() { - grep -v '/main\.go$' +integrations() { + find tests/ -name '*.sh' -type f -perm -111 } -only_main() { - grep '/main\.go$' +fuzzs() { + find tests/fuzz/*.clj } -sources() { - find src tests -name '*.go' | grep -v '^src/version\.go$' -} - -libs() { - sources | no_main -} - -mains() { - sources | only_main -} -libs | varlist 'libs.go' -mains | varlist 'mains.go' +find doc/*.en.*.adoc | varlist 'manpages.en.N.adoc' -find tests/functional/*/*.go | no_main | varlist 'functional-tests/lib.go' -find tests/functional/*/main.go | varlist 'functional-tests/main.go' -find tests/fuzz/*/*.go | no_main | varlist 'fuzz-targets/lib.go' -find tests/fuzz/*/main.go | varlist 'fuzz-targets/main.go' -find tests/benchmarks/*/*.go | no_main | varlist 'benchmarks/lib.go' -find tests/benchmarks/*/main.go | varlist 'benchmarks/main.go' +integrations | varlist 'integration.sh' +fuzzs | varlist 'fuzz.clj' +fuzzs | sed 's/\.clj$/__init.class/' | varlist 'fuzzinit.class' -sources | sort | sed 's/^\(.*\)\.go$/\1.a:\t\1.go/' -mains | sort | sed 's/^\(.*\)\.go$/\1.bin:\t\1.a/' -mains | sort | sed 's/^\(.*\)\.go$/\1.bin-check:\t\1.bin/' -mains | sort | sed 's|^\(.*\)/main\.go$|\1/main.a:\t\1/$(NAME).a|' +integrations | sed 's/^\(.*\)\.sh$/\1.sh-check:\t\1.sh/' +fuzzs | sed 's/^\(.*\)\.clj$/\1__init.class:\t\1.clj/' +fuzzs | sed 's/^\(.*\)\.clj$/\1.class-check:\t\1__init.class/' diff --git a/po/doc/de.po b/po/doc/de.po new file mode 100644 index 0000000..0c94c9f --- /dev/null +++ b/po/doc/de.po @@ -0,0 +1,22 @@ +msgid "" +msgstr "" +"Language: de\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. type: Title = +#: doc/papod.en.0.adoc:1 +#, no-wrap +msgid "papod(0)" +msgstr "" + +#. type: Title == +#: doc/papod.en.0.adoc:3 +#, no-wrap +msgid "NAME" +msgstr "" + +#. type: Plain text +#: doc/papod.en.0.adoc:5 +msgid "papod - ." +msgstr "" diff --git a/po/doc/doc.pot b/po/doc/doc.pot new file mode 100644 index 0000000..f0a23c6 --- /dev/null +++ b/po/doc/doc.pot @@ -0,0 +1,21 @@ +msgid "" +msgstr "" +"Language: \n" +"Content-Type: text/plain; charset=UTF-8\n" + +#. type: Title = +#: doc/papod.en.0.adoc:1 +#, no-wrap +msgid "papod(0)" +msgstr "" + +#. type: Title == +#: doc/papod.en.0.adoc:3 +#, no-wrap +msgid "NAME" +msgstr "" + +#. type: Plain text +#: doc/papod.en.0.adoc:5 +msgid "papod - ." +msgstr "" diff --git a/po/doc/eo.po b/po/doc/eo.po new file mode 100644 index 0000000..5ad3a4a --- /dev/null +++ b/po/doc/eo.po @@ -0,0 +1,22 @@ +msgid "" +msgstr "" +"Language: eo\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. type: Title = +#: doc/papod.en.0.adoc:1 +#, no-wrap +msgid "papod(0)" +msgstr "" + +#. type: Title == +#: doc/papod.en.0.adoc:3 +#, no-wrap +msgid "NAME" +msgstr "" + +#. type: Plain text +#: doc/papod.en.0.adoc:5 +msgid "papod - ." +msgstr "" diff --git a/po/doc/es.po b/po/doc/es.po new file mode 100644 index 0000000..2222c03 --- /dev/null +++ b/po/doc/es.po @@ -0,0 +1,22 @@ +msgid "" +msgstr "" +"Language: es\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. type: Title = +#: doc/papod.en.0.adoc:1 +#, no-wrap +msgid "papod(0)" +msgstr "" + +#. type: Title == +#: doc/papod.en.0.adoc:3 +#, no-wrap +msgid "NAME" +msgstr "" + +#. type: Plain text +#: doc/papod.en.0.adoc:5 +msgid "papod - ." +msgstr "" diff --git a/po/doc/fr.po b/po/doc/fr.po new file mode 100644 index 0000000..12322c3 --- /dev/null +++ b/po/doc/fr.po @@ -0,0 +1,22 @@ +msgid "" +msgstr "" +"Language: fr\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#. type: Title = +#: doc/papod.en.0.adoc:1 +#, no-wrap +msgid "papod(0)" +msgstr "" + +#. type: Title == +#: doc/papod.en.0.adoc:3 +#, no-wrap +msgid "NAME" +msgstr "" + +#. type: Plain text +#: doc/papod.en.0.adoc:5 +msgid "papod - ." +msgstr "" diff --git a/po/doc/note.txt b/po/doc/note.txt new file mode 100644 index 0000000..45279a4 --- /dev/null +++ b/po/doc/note.txt @@ -0,0 +1,5 @@ +PO4A-HEADER: mode=eof + + + +// Generated from po4a(1). diff --git a/po/doc/po4a.cfg b/po/doc/po4a.cfg new file mode 100644 index 0000000..6069090 --- /dev/null +++ b/po/doc/po4a.cfg @@ -0,0 +1,5 @@ +[options] --keep 0 --master-charset UTF-8 --localized-charset UTF-8 --addendum-charset UTF-8 + +[po_directory] po/doc + +[type: asciidoc] doc/papod.en.0.adoc $lang:doc/papod.$lang.0.adoc diff --git a/po/doc/pt.po b/po/doc/pt.po new file mode 100644 index 0000000..31189a9 --- /dev/null +++ b/po/doc/pt.po @@ -0,0 +1,22 @@ +msgid "" +msgstr "" +"Language: pt\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. type: Title = +#: doc/papod.en.0.adoc:1 +#, no-wrap +msgid "papod(0)" +msgstr "" + +#. type: Title == +#: doc/papod.en.0.adoc:3 +#, no-wrap +msgid "NAME" +msgstr "" + +#. type: Plain text +#: doc/papod.en.0.adoc:5 +msgid "papod - ." +msgstr "" diff --git a/po/papod/de.po b/po/papod/de.po new file mode 100644 index 0000000..8c86b52 --- /dev/null +++ b/po/papod/de.po @@ -0,0 +1,10 @@ +msgid "" +msgstr "" +"Language: de\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: src/papod.go:4583 +#, c-format +msgid "Failed to start: %v\n" +msgstr "" diff --git a/po/papod/eo.po b/po/papod/eo.po new file mode 100644 index 0000000..cfe07ec --- /dev/null +++ b/po/papod/eo.po @@ -0,0 +1,10 @@ +msgid "" +msgstr "" +"Language: eo\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: src/papod.go:4583 +#, c-format +msgid "Failed to start: %v\n" +msgstr "" diff --git a/po/papod/es.po b/po/papod/es.po new file mode 100644 index 0000000..62bb588 --- /dev/null +++ b/po/papod/es.po @@ -0,0 +1,10 @@ +msgid "" +msgstr "" +"Language: es\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: src/papod.go:4583 +#, c-format +msgid "Failed to start: %v\n" +msgstr "" diff --git a/po/papod/fr.po b/po/papod/fr.po new file mode 100644 index 0000000..885cb55 --- /dev/null +++ b/po/papod/fr.po @@ -0,0 +1,10 @@ +msgid "" +msgstr "" +"Language: fr\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: src/papod.go:4583 +#, c-format +msgid "Failed to start: %v\n" +msgstr "" diff --git a/po/papod/papod.pot b/po/papod/papod.pot new file mode 100644 index 0000000..62f9ab4 --- /dev/null +++ b/po/papod/papod.pot @@ -0,0 +1,10 @@ +msgid "" +msgstr "" +"Language: \n" +"Content-Type: text/plain; charset=UTF-8\n" + +#: src/papod.go:4583 +#, c-format +msgid "Failed to start: %v\n" +msgstr "" + diff --git a/po/papod/po4a.cfg b/po/papod/po4a.cfg new file mode 100644 index 0000000..860e920 --- /dev/null +++ b/po/papod/po4a.cfg @@ -0,0 +1,3 @@ +[options] --keep 0 --master-charset UTF-8 --localized-charset UTF-8 --addendum-charset UTF-8 + +[po_directory] po/papod diff --git a/po/papod/pt.po b/po/papod/pt.po new file mode 100644 index 0000000..518789e --- /dev/null +++ b/po/papod/pt.po @@ -0,0 +1,10 @@ +msgid "" +msgstr "" +"Language: pt\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: src/papod.go:4583 +#, c-format +msgid "Failed to start: %v\n" +msgstr "" diff --git a/src/labareda.edn b/src/labareda.edn new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/labareda.edn diff --git a/src/main.go b/src/main.go deleted file mode 100644 index b591f5c..0000000 --- a/src/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "papod" - -func main() { - papod.Main() -} diff --git a/src/papod.bin.in b/src/papod.bin.in new file mode 100755 index 0000000..1a25c3b --- /dev/null +++ b/src/papod.bin.in @@ -0,0 +1,5 @@ +#!/bin/sh +set -euo pipefail + +export CLASSPATH="@JAVADIR@/@NAME@.jar:@CLASSPATH@${CLASSPATH:+:}${CLASSPATH:-}" +exec java -client @NAME@ "$@" diff --git a/src/papod.clj b/src/papod.clj new file mode 100644 index 0000000..f886ae2 --- /dev/null +++ b/src/papod.clj @@ -0,0 +1,7001 @@ +(ns + ^{:sections ["bleh"]} + papod + (:require [base :refer [def- defconst- third]] + [clojure.set :as set] + [clojure.string :as string] + [cracha] + [datomic.api :as d] + [fiinha]) + (:import (java.net InetSocketAddress StandardProtocolFamily + UnixDomainSocketAddress) + (java.nio.channels ServerSocketChannel SocketChannel) + (java.nio.file Files)) + (:gen-class)) + + + +(def non-empty? + (complement empty?)) + +(def- non-empty?' + {:db.attr/preds `non-empty?}) + +(def- id' + {:db/unique :db.unique/identity}) + +(def- unique' + {:db/unique :db.unique/value}) + +(def- index' + {:db/index true}) + +(def add-event-seq + (d/function + {:lang "clojure" + :params '[db event-eid channel-uuid] + :code + '(let [channel-eid (or (ffirst + (d/q '{:find [?e] + :in [$ ?id] + :where [[?e :papod.channel/id ?id]]} + db channel-uuid)) + (d/tempid :db.part/user)) + last-seq (or (:papod.channel/last-event-seq + (d/entity db channel-eid)) 0) + new-seq (inc last-seq)] + [[:db/add channel-eid :papod.channel/id channel-uuid] + [:db/add channel-eid :papod.channel/last-event-seq new-seq] + [:db/add event-eid :papod.event/seq new-seq]])})) + +(def ensure-access-unique + (d/function + {:lang "clojure" + :params '[db channel nick level] + :code + '(when (seq + (d/q '{:find [?a] + :in [$ ?ch ?n ?l] + :where [[?a :papod.access/channel ?ch] + [?a :papod.access/nick ?n] + [?a :papod.access/level ?l]]} + db channel nick level)) + (d/cancel + {:cognitect.anomalies/category :cognitect.anomalies/conflict + :cognitect.anomalies/message "Duplicate access entry"}))})) + +(def- schema + [;; Network + {:db/ident :papod.network/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.network/name + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value + :db.attr/preds `non-empty?} + {:db/ident :papod.network/description + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :papod.network/type + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true + :db.attr/preds `non-empty?} + {:db/ident :papod.network/created-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + ;; Member (per-network user profile) + {:db/ident :papod.member/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.member/network + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.member/nick + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true + :db.attr/preds `non-empty?} + {:db/ident :papod.member/status + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.member/joined-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + {:db/ident :papod.member/network+nick + :db/valueType :db.type/tuple + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value + :db/tupleAttrs [:papod.member/network + :papod.member/nick]} + ;; Member Role + {:db/ident :papod.member-role/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.member-role/member + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.member-role/role + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.member-role/member+role + :db/valueType :db.type/tuple + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value + :db/tupleAttrs [:papod.member-role/member + :papod.member-role/role]} + ;; Channel + {:db/ident :papod.channel/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.channel/network + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.channel/name + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value} + {:db/ident :papod.channel.type/public} + {:db/ident :papod.channel.type/private} + {:db/ident :papod.channel.type/unlisted} + {:db/ident :papod.channel/type + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.channel/label + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :papod.channel/description + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :papod.channel/created-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + {:db/ident :papod.channel/owner + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.channel/topic + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :papod.channel/topic-set-by + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :papod.channel/topic-set-at + :db/valueType :db.type/long + :db/cardinality :db.cardinality/one} + {:db/ident :papod.channel/last-event-seq + :db/valueType :db.type/long + :db/cardinality :db.cardinality/one} + ;; Access control + {:db/ident :papod.access/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.access/channel + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.access/nick + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true + :db.attr/preds `non-empty?} + {:db/ident :papod.access/level + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.access/channel+nick+level + :db/valueType :db.type/tuple + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value + :db/tupleAttrs [:papod.access/channel + :papod.access/nick + :papod.access/level]} + ;; Memo + {:db/ident :papod.memo/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.memo/from + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.memo/to + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true + :db.attr/preds `non-empty?} + {:db/ident :papod.memo/content + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.memo/created-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + {:db/ident :papod.memo/read? + :db/valueType :db.type/boolean + :db/cardinality :db.cardinality/one} + ;; Membership + {:db/ident :papod.membership/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.membership/channel + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.membership/nick + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true + :db.attr/preds `non-empty?} + {:db/ident :papod.membership/joined-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + {:db/ident :papod.membership/channel+nick + :db/valueType :db.type/tuple + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value + :db/tupleAttrs [:papod.membership/channel + :papod.membership/nick]} + ;; Session (auth record, references cracha session) + {:db/ident :papod.session/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.session/nick + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.session/created-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + {:db/ident :papod.session/finished-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + ;; Connection (TCP socket lifecycle) + {:db/ident :papod.connection/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.connection/process + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.connection/created-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + {:db/ident :papod.connection/finished-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + ;; Logon (session + connection junction) + {:db/ident :papod.logon/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.logon/session + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.logon/connection + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.logon/session+connection + :db/valueType :db.type/tuple + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value + :db/tupleAttrs [:papod.logon/session + :papod.logon/connection]} + ;; Process (server instance) + {:db/ident :papod.process/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.process/pid + :db/valueType :db.type/long + :db/cardinality :db.cardinality/one} + {:db/ident :papod.process/hostname + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :papod.process/started-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + ;; Event + {:db/ident :papod.event/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.event/channel + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.event/target-nick + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true + :db.attr/preds `non-empty?} + {:db/ident :papod.event/type + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true + :db.attr/preds `non-empty?} + {:db/ident :papod.event/source-nick + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.event/source-account + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.event/target-account + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.event/payload + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :papod.event/created-at + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + {:db/ident :papod.event/seq + :db/valueType :db.type/long + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.event/reply-to + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.event/edit-of + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.event/delete-of + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + ;; Reaction + {:db/ident :papod.reaction/id + :db/valueType :db.type/uuid + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity} + {:db/ident :papod.reaction/event + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.reaction/nick + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.reaction/emoji + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.reaction/event+nick+emoji + :db/valueType :db.type/tuple + :db/cardinality :db.cardinality/one + :db/unique :db.unique/value + :db/tupleAttrs [:papod.reaction/event + :papod.reaction/nick + :papod.reaction/emoji]} + ;; Read marker + {:db/ident :papod.read-marker/channel + :db/valueType :db.type/ref + :db/cardinality :db.cardinality/one + :db/index true} + {:db/ident :papod.read-marker/nick + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db/index true + :db.attr/preds `non-empty?} + {:db/ident :papod.read-marker/timestamp + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one + :db.attr/preds `non-empty?} + {:db/ident :papod.read-marker/channel+nick + :db/valueType :db.type/tuple + :db/cardinality :db.cardinality/one + :db/unique :db.unique/identity + :db/tupleAttrs [:papod.read-marker/channel + :papod.read-marker/nick]} + ;; Entity attr groups + {:db/ident :papod.network/attrs + :db.entity/attrs #{:papod.network/id + :papod.network/name + :papod.network/type + :papod.network/created-at}} + {:db/ident :papod.member/attrs + :db.entity/attrs #{:papod.member/id + :papod.member/network + :papod.member/nick + :papod.member/status + :papod.member/joined-at}} + {:db/ident :papod.member-role/attrs + :db.entity/attrs #{:papod.member-role/id + :papod.member-role/member + :papod.member-role/role}} + {:db/ident :papod.channel/attrs + :db.entity/attrs #{:papod.channel/id + :papod.channel/network + :papod.channel/type + :papod.channel/created-at}} + {:db/ident :papod.access/attrs + :db.entity/attrs #{:papod.access/id + :papod.access/channel + :papod.access/nick + :papod.access/level}} + {:db/ident :papod.memo/attrs + :db.entity/attrs #{:papod.memo/id + :papod.memo/from + :papod.memo/to + :papod.memo/content + :papod.memo/created-at + :papod.memo/read?}} + {:db/ident :papod.membership/attrs + :db.entity/attrs #{:papod.membership/id + :papod.membership/channel + :papod.membership/nick + :papod.membership/joined-at}} + {:db/ident :papod.session/attrs + :db.entity/attrs #{:papod.session/id + :papod.session/nick + :papod.session/created-at}} + {:db/ident :papod.process/attrs + :db.entity/attrs #{:papod.process/id + :papod.process/pid + :papod.process/hostname + :papod.process/started-at}} + {:db/ident :papod.connection/attrs + :db.entity/attrs #{:papod.connection/id + :papod.connection/process + :papod.connection/created-at}} + {:db/ident :papod.logon/attrs + :db.entity/attrs #{:papod.logon/id + :papod.logon/session + :papod.logon/connection}} + {:db/ident :papod.event/attrs + :db.entity/attrs #{:papod.event/id + :papod.event/type + :papod.event/source-nick + :papod.event/created-at}} + {:db/ident :papod.reaction/attrs + :db.entity/attrs #{:papod.reaction/id + :papod.reaction/event + :papod.reaction/nick + :papod.reaction/emoji}} + ;; Database functions + {:db/ident :papod.event/add-seq + :db/fn add-event-seq} + {:db/ident :papod.access/ensure-unique + :db/fn ensure-access-unique}]) + +(defconst- +delimiter+ + "\r\n") + +(defconst- +separator+ + \space) + +(def- +server-name+ + (or (System/getenv "PAPOD_SERVER_NAME") + "papod")) + +(def- +server-password+ + (System/getenv "PAPOD_PASSWORD")) + +;; Defaults intended for a hosted-service deployment: register +;; before completing the connection (one round-trip account creation +;; for web clients) and require an email (recovery, abuse mitigation). +;; Tests opt out by setting the env to "0". +(def- +reg-before-connect?+ + (not= "0" (System/getenv "PAPOD_REG_BEFORE_CONNECT"))) + +(def- +reg-email-required?+ + (not= "0" (System/getenv "PAPOD_REG_EMAIL_REQUIRED"))) + +(defconst- +version+ + "0.1.0") + +(defn- set-of-chars + [min max] + {:pre [(char? min) + (char? max)] + :post [(every? char? %)]} + (->> [(int min) (inc (int max))] + (apply range) + (map char) + (into #{}))) + +(defconst- +digits+ + (set-of-chars \0 \9)) + +(defconst- +letters+ + (set/union + (set-of-chars \A \Z) + (set-of-chars \a \z))) + +(defn- digit? + [c] + (+digits+ c)) + +(defn- letter? + [c] + (+letters+ c)) + +(defn- get-raw-message + [input] + {:pre [(string? input)] + :post [(map? %) + (string? (:input %)) + (#{:needs-more :accepting} (:status %)) + (let [input'old input + {:keys [status input raw-message]} %] + (if (= :needs-more status) + (and (= input'old input) + (nil? raw-message)) + (or (false? raw-message) + (and (= input'old (str raw-message + +delimiter+ + input)) + (nil? (string/index-of raw-message +delimiter+))))))]} + (let [index (string/index-of input +delimiter+)] + (if-not index + {:status :needs-more + :input input} + (let [payload (subs input 0 index) + rest (subs input (+ index (count +delimiter+)))] + {:status :accepting + :input rest + :raw-message (and (not (string/blank? payload)) + payload)})))) + +(defn- parse-tags + [s] + {:pre [(string/starts-with? s "@")] + :post [(let [[tags offset] %] + (and (map? tags) + (number? offset) + (pos? offset)))]} + (let [end (string/index-of s +separator+)] + (if-not end + [{} (count s)] + (let [tag-str (subs s 1 end) + pairs (string/split tag-str #";")] + [(->> pairs + (map (fn [pair] + (let [eq (string/index-of pair "=")] + (if eq + [(subs pair 0 eq) (subs pair (inc eq))] + [pair nil])))) + (into {})) + (inc end)])))) + +(defn- valid-err? + [[x offset {:keys [message type data] :as err}]] + (and (nil? x) + (nil? offset) + (map? err) + (string? message) + (keyword? type) + (map? data))) + +(defn- valid-prefix? + [{:keys [nick user host]}] + (and (string? nick) + (not (string/blank? nick)) + (or (nil? user) (and (string? user) (not (string/blank? user)))) + (or (nil? host) (and (string? host) (not (string/blank? host)))))) + +(defn- parse-prefix-components + [s] + {:pre [(string/starts-with? s ":") + (let [end (string/index-of s +separator+)] + (and end (> end 1)))] + :post [(let [[prefix offset err] %] + (if err + (valid-err? %) + (and (valid-prefix? prefix) + (number? offset) + (pos? offset) + (nil? err))))]} + (let [end (string/index-of s +separator+) + text (subs s 1 end) + offset (inc end) + bang (string/index-of text "!") + at (string/index-of text "@") + valid-order? (or (not bang) (not at) (< bang at)) + nick (when valid-order? + (cond bang (subs text 0 bang) + at (subs text 0 at) + :else text)) + user (when (and valid-order? bang) + (subs text (inc bang) (or at (count text)))) + host (when (and valid-order? at) + (subs text (inc at)))] + (cond + (not valid-order?) + [nil nil {:message "Bad prefix format" + :type :bad-prefix-format + :data {:prefix text}}] + + (string/blank? nick) + [nil nil {:message "Empty nick in prefix" + :type :bad-empty-nick + :data {:prefix text}}] + + (and user (string/blank? user)) + [nil nil {:message "Empty user in prefix" + :type :bad-empty-user + :data {:prefix text}}] + + (and host (string/blank? host)) + [nil nil {:message "Empty host in prefix" + :type :bad-empty-host + :data {:prefix text}}] + + :else + [{:nick nick :user user :host host} offset]))) + +(defn- parse-prefix + [s] + {:pre [(string/starts-with? s ":")] + :post [(let [[prefix offset err] %] + (if err + (valid-err? %) + (and (valid-prefix? prefix) + (number? offset) + (pos? offset) + (nil? err))))]} + (let [end (string/index-of s +separator+)] + (cond + (= 1 (count s)) + [nil nil {:message "Empty prefix and empty message" + :type :bad-empty-prefix-and-message + :data {}}] + (= 1 end) + [nil nil {:message "Empty prefix" + :type :bad-empty-prefix + :data {}}] + (nil? end) + [nil nil {:message "Missing prefix separator" + :type :no-prefix-separator + :data {}}] + :else + (parse-prefix-components s)))) + +(defn- parse-command + [s] + {:pre [(not (string/blank? s))] + :post [(let [[command offset err] %] + (if err + (valid-err? %) + (and (string? command) + (not (string/blank? command)) + (pos? offset) + (= offset (count command)) + (nil? err))))]} + (loop [chars [] + index 0] + (if (or (= index (count s)) + (= +separator+ (nth s index))) + (if (zero? (count chars)) + [nil nil {:message "Empty command" + :type :empty-command + :data {}}] + [(apply str chars) (count chars)]) + (recur (conj chars (nth s index)) + (inc index))))) + +(defconst- params-re + (java.util.regex.Pattern/compile (str +separator+))) + +(defn- parse-params + [s] + {:pre [(string? s)] + :post [(nil? (third %)) + (vector? (first %)) + (every? string? (first %)) + (= (count s) (second %))]} + (if (zero? (count s)) + [[] 0] + (loop [[part & rest] (string/split s params-re -1) + out []] + (cond + (not part) + [out (count s)] + (string/starts-with? part ":") + [(conj out + (string/join (str +separator+) (cons part rest))) + (count s)] + :else + (recur rest (conj out part)))))) + +(defn- valid-message? + [{:keys [tags prefix command params] :as message}] + (and (map? message) + (or (nil? tags) (map? tags)) + (or (nil? prefix) + (valid-prefix? prefix)) + (string? command) + (vector? params) + (every? string? params))) + +(defn- parse-message + [raw-message] + {:pre [(not (string/blank? raw-message))] + :post [(let [[message err] %] + (if err + (valid-err? [nil nil err]) + (valid-message? message)))]} + (let [[tags offset0] (if (string/starts-with? raw-message "@") + (parse-tags raw-message) + [nil 0]) + rest0 (subs raw-message offset0) + [prefix offset1 err] (if (string/starts-with? rest0 ":") + (parse-prefix rest0) + [nil 0])] + (if err + [nil err] + (let [[command offset2 err] (parse-command (subs rest0 offset1))] + (if err + [nil err] + (let [[params _ err] (parse-params (subs rest0 + (+ offset1 offset2)))] + (if err + [nil err] + [{:tags tags + :prefix prefix + :command command + :params params} + nil]))))))) + +(defconst- +buffer-size+ + 1024) + +(defconst- +charset+ + "UTF-8") + +;; PROXY protocol v2: untls injects a header on every accepted +;; connection carrying the SNI as PP2_TYPE_AUTHORITY. We use that +;; authority string to resolve the network the client is joining. +;; Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt + +(def- +proxy-v2-signature+ + (byte-array + [(unchecked-byte 0x0D) (unchecked-byte 0x0A) + (unchecked-byte 0x0D) (unchecked-byte 0x0A) + (unchecked-byte 0x00) (unchecked-byte 0x0D) + (unchecked-byte 0x0A) (unchecked-byte 0x51) + (unchecked-byte 0x55) (unchecked-byte 0x49) + (unchecked-byte 0x54) (unchecked-byte 0x0A)])) + +(defconst- +pp2-type-authority+ 0x02) + +(def- +network-name-fallback+ + (System/getenv "PAPOD_NETWORK_NAME")) + +(defn- read-fully! + [^java.io.InputStream is n] + (let [buf (byte-array n)] + (loop [off 0] + (cond + (= off n) buf + + :else + (let [r (.read is buf off (- n off))] + (if (pos? r) (recur (+ off r)) nil)))))) + +(defn- parse-proxy-v2-tlvs + "Walk a PROXY v2 TLV body looking for AUTHORITY (0x02). Returns + the value as a String, or empty string if absent / malformed." + [^bytes body] + (let [n (alength body)] + (loop [i 0] + (cond + (> (+ i 3) n) "" + + :else + (let [t (bit-and (aget body i) 0xFF) + vl (bit-or (bit-shift-left + (bit-and (aget body (+ i 1)) 0xFF) 8) + (bit-and (aget body (+ i 2)) 0xFF)) + vs (+ i 3)] + (cond + (> (+ vs vl) n) "" + + (= t +pp2-type-authority+) + (String. body vs vl ^String +charset+) + + :else (recur (+ vs vl)))))))) + +(defn- read-proxy-v2-authority! + "If the next bytes on `bis` are a PROXY v2 header, consume the + full header and return the AUTHORITY TLV value (or empty string + if absent). If the bytes are not a PROXY v2 header, reset the + stream so they can be read normally, and return nil." + [^java.io.BufferedInputStream bis] + (.mark bis 16) + (let [first12 (read-fully! bis 12)] + (cond + (nil? first12) + (do (try (.reset bis) (catch Exception _)) nil) + + (not (java.util.Arrays/equals + ^bytes first12 ^bytes +proxy-v2-signature+)) + (do (.reset bis) nil) + + :else + (let [hdr (read-fully! bis 4)] + (when hdr + (let [ver-cmd (bit-and (aget hdr 0) 0xFF) + len (bit-or + (bit-shift-left + (bit-and (aget hdr 2) 0xFF) 8) + (bit-and (aget hdr 3) 0xFF)) + body (if (pos? len) + (read-fully! bis len) + (byte-array 0))] + (when (and body (= ver-cmd 0x21)) + (parse-proxy-v2-tlvs body)))))))) + +(defn- find-or-create-network! + [conn authority] + (let [lookup #(ffirst + (d/q '{:find [?id] + :in [$ ?n] + :where [[?e :papod.network/name ?n] + [?e :papod.network/id ?id]]} + (d/db conn) %))] + (or (lookup authority) + (let [new-id (java.util.UUID/randomUUID)] + (try + @(d/transact conn + [{:db/ensure :papod.network/attrs + :papod.network/id new-id + :papod.network/name authority + :papod.network/description "" + :papod.network/type "public" + :papod.network/created-at (java.util.Date.)}]) + new-id + (catch Exception _ + ;; Race: another connection just created it. + (lookup authority))))))) + +(defn- client-target + [client] + (or (:nick @client) "*")) + +(defn- client-prefix + "Source prefix for messages sent on behalf of a client. + Format: nick!user@host (user/host fall back when missing)." + [client] + (let [nick (or (:nick @client) "*") + u (:user @client) + user (or (:username u) nick) + host (or (:host @client) "localhost")] + (str nick "!" user "@" host))) + +(defn- numeric-reply + [client code & parts] + (str ":" +server-name+ " " code " " (client-target client) + (when (seq parts) + (str " " (string/join " " parts))))) + +(defn- clean-params + [params] + (filterv (complement string/blank?) params)) + +(defn- iso-time + ([] (iso-time (java.util.Date.))) + ([^java.util.Date d] + (let [fmt (java.text.SimpleDateFormat. "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")] + (.setTimeZone fmt (java.util.TimeZone/getTimeZone "UTC")) + (.format fmt d)))) + +(defn- tag-line + [msg-id line] + (str "@msgid=" msg-id ";time=" (iso-time) " " line)) + +(defn- tag-line-with + [msg-id tags line] + (str "@msgid=" msg-id ";time=" (iso-time) + (when (seq tags) + (str ";" (string/join ";" tags))) + " " line)) + +(defn- tag-line-at + [msg-id ^java.util.Date at tags line] + (str "@msgid=" msg-id ";time=" (iso-time at) + (when (seq tags) + (str ";" (string/join ";" tags))) + " " line)) + +(defn- fetch-history-all + [db channel-eid] + (let [redacted (->> (d/q '{:find [?target] + :in [$ ?ch] + :where [[?e :papod.event/channel ?ch] + [?e :papod.event/type "user-delete"] + [?e :papod.event/delete-of ?target]]} + db channel-eid) + (map first) + set)] + (->> (d/q '{:find [?seq ?type ?nick ?id + (pull ?e [:db/id + :papod.event/payload + :papod.event/created-at + :papod.event/edit-of + :papod.event/reply-to])] + :in [$ ?ch] + :where [[?e :papod.event/channel ?ch] + [?e :papod.event/seq ?seq] + [?e :papod.event/type ?type] + [?e :papod.event/source-nick ?nick] + [?e :papod.event/id ?id]]} + db channel-eid) + (remove (fn [[_ type _ _ pulled]] + (or (= type "user-delete") + (contains? redacted (:db/id pulled))))) + (sort-by first)))) + +(defn- fetch-dm-history-all + [db nick nick-account peer] + (let [n-low (string/lower-case nick) + p-low (string/lower-case peer) + rows (d/q '{:find [?source ?type ?id ?ts ?s-acct ?t-acct + (pull ?e [:db/id + :papod.event/payload + :papod.event/target-nick + :papod.event/created-at + :papod.event/edit-of + :papod.event/reply-to])] + :in [$ ?n-low ?p-low] + :where [[?e :papod.event/source-nick ?source] + [?e :papod.event/target-nick ?target] + [?e :papod.event/type ?type] + [?e :papod.event/id ?id] + [?e :papod.event/created-at ?ts] + [(get-else $ ?e + :papod.event/source-account + "") ?s-acct] + [(get-else $ ?e + :papod.event/target-account + "") ?t-acct] + [(clojure.string/lower-case ?source) ?s-low] + [(clojure.string/lower-case ?target) ?t-low] + (or-join [?n-low ?p-low ?s-low ?t-low] + (and [(= ?s-low ?n-low)] + [(= ?t-low ?p-low)]) + (and [(= ?s-low ?p-low)] + [(= ?t-low ?n-low)]))]} + db n-low p-low) + my-acct (or nick-account "")] + (->> rows + (filter (fn [[source _ _ _ s-acct t-acct _]] + (let [me-source? (= (string/lower-case source) n-low) + their-acct (if me-source? s-acct t-acct)] + (= their-acct my-acct)))) + (map (fn [[source type id ts _ _ pulled]] + [(.getTime ^java.util.Date ts) type + source id pulled])) + (sort-by first)))) + +(defn- fetch-history + [db channel-eid after-seq limit] + (->> (fetch-history-all db channel-eid) + (filter (fn [[seq _ _ _ _]] (> seq after-seq))) + (take limit))) + +(defn- channel-handle? + [s] + (or (string/starts-with? s "#") + (string/starts-with? s "&"))) + +(defn- statusmsg-prefix + [s] + (when (and (seq s) + (#{\@ \+} (first s)) + (or (string/starts-with? (subs s 1) "#") + (string/starts-with? (subs s 1) "&"))) + (str (first s)))) + +(defn- resolve-channel + [db handle] + (cond + (string/starts-with? handle "&") + (try + (let [uuid (java.util.UUID/fromString (subs handle 1))] + (ffirst + (d/q '{:find [?e] + :in [$ ?id] + :where [[?e :papod.channel/id ?id] + [?e :papod.channel/type :papod.channel.type/private]]} + db uuid))) + (catch Exception _ nil)) + + (string/starts-with? handle "#") + (ffirst + (d/q '{:find [?e] + :in [$ ?name] + :where [[?e :papod.channel/name ?name] + [?e :papod.channel/type ?t] + [(not= ?t :papod.channel.type/private)]]} + db handle)))) + +(defn- has-access? + [db channel-eid nick levels] + (boolean + (seq + (d/q '{:find [?a] + :in [$ ?channel ?nick [?level ...]] + :where [[?a :papod.access/channel ?channel] + [?a :papod.access/nick ?nick] + [?a :papod.access/level ?level]]} + db channel-eid nick levels)))) + +(defn- pending-memos + [conn nick] + (when conn + (seq + (d/q '{:find [?id ?from] + :in [$ ?nick] + :where [[?m :papod.memo/to ?nick] + [?m :papod.memo/from ?from] + [?m :papod.memo/id ?id] + [?m :papod.memo/read? false]]} + (d/db conn) nick)))) + +(defn- truncate-utf8-bytes + ^bytes [^bytes bytes max-len] + (let [n (alength bytes)] + (if (<= n max-len) + bytes + (loop [i 0] + (if (>= i n) + bytes + (let [b (bit-and (aget bytes i) 0xff) + len (cond + (zero? (bit-and b 0x80)) 1 + (= (bit-and b 0xe0) 0xc0) 2 + (= (bit-and b 0xf0) 0xe0) 3 + (= (bit-and b 0xf8) 0xf0) 4 + :else 1) + end (+ i len)] + (if (or (> end n) (> end max-len)) + (java.util.Arrays/copyOfRange bytes 0 i) + (recur end)))))))) + +(defn- deliver-to-client! + [w line] + (try + (let [s (str line) + delim (.getBytes +delimiter+ +charset+) + ;; If the line is tagged (starts with @), keep + ;; the tag prefix intact and truncate only the + ;; message body. Tags can be up to 4094 bytes; + ;; the body has its own 510-byte cap. + [pre body-str] + (if (string/starts-with? s "@") + (let [sp (.indexOf s " ")] + (if (pos? sp) + [(subs s 0 (inc sp)) (subs s (inc sp))] + ["" s])) + ["" s]) + pre-bytes (.getBytes ^String pre +charset+) + body-bytes (.getBytes ^String body-str +charset+) + body' (truncate-utf8-bytes body-bytes + (- 512 (alength delim))) + out (byte-array (+ (alength pre-bytes) + (alength body') + (alength delim)))] + (System/arraycopy pre-bytes 0 out 0 + (alength pre-bytes)) + (System/arraycopy body' 0 out (alength pre-bytes) + (alength body')) + (System/arraycopy delim 0 out + (+ (alength pre-bytes) (alength body')) + (alength delim)) + (.write w out 0 (alength out)) + (.flush w)) + (catch Exception _))) + +(defn- handle-pass + [params client components] + (cond + (:registered? @client) + [(numeric-reply client "462" ":You may not reregister")] + + (empty? params) + [(numeric-reply client "461" "PASS :Not enough parameters")] + + +server-password+ + (do (swap! client assoc :password (first params)) []) + + :else + (let [uuid-str (first params) + conn (:conn components)] + (if-not conn + [] + (try + (let [uuid (java.util.UUID/fromString uuid-str) + db (d/db conn) + eid (ffirst + (d/q '{:find [?e] + :in [$ ?id] + :where [[?e :papod.network/id ?id]]} + db uuid))] + (if eid + (do (swap! client assoc :network-id uuid) + []) + [(numeric-reply client "403" + (str uuid-str " :No such network"))])) + (catch Exception _ + [(numeric-reply client "403" + (str uuid-str " :Invalid network ID"))])))))) + +(defn- cracha-conn + [components] + (:conn (:cracha components))) + +(defn- nick-account + [components nick] + (when-let [c (cracha-conn components)] + (when (cracha/user-by-email c nick) + nick))) + +(declare notify-extended-monitors!) + +(defn- broadcast-account-notify! + "Send :nick ACCOUNT <account-or-*> to every other member of every + channel `nick` is in, gated on the recipient negotiating account- + notify. Also fans out to extended-monitor watchers." + [components nick account] + (let [{:keys [clients channels]} components + line (str ":" nick " ACCOUNT " (or account "*")) + seen (atom #{nick})] + (when (and clients channels) + (doseq [[handle members] @channels + :when (contains? members nick) + mn members + :when (and (not= mn nick) + (not (@seen mn)))] + (swap! seen conj mn) + (when-let [m (get @clients mn)] + (let [ca (:client-atom m) + caps (when ca (or (:caps @ca) #{}))] + (when (caps "account-notify") + (deliver-to-client! (:w m) line)))))) + (notify-extended-monitors! + components nick "account-notify" line seen))) + +(defn- authenticate! + [client _components] + (if +server-password+ + (= +server-password+ (:password @client)) + true)) + +(declare join-one!) +(declare notify-monitors!) +(declare ban-matches?) +(declare mute-matches?) +(declare mute-except-matches?) +(declare nuh-for-nick) + +(defn- persistent-channels + "Channel handles where `nick` has stored membership." + [conn nick] + (when conn + (->> (d/q '{:find [?h] + :in [$ ?nick] + :where + [[?m :papod.membership/channel ?ch] + [?m :papod.membership/nick ?nick] + [?ch :papod.channel/name ?h]]} + (d/db conn) nick) + (mapv first)))) + +(defn- auto-rejoin-replies + [client components] + (when (and (:authenticated? @client) + (:account @client)) + (let [nick (client-target client) + conn (:conn components) + chans (persistent-channels conn nick)] + (vec (mapcat #(join-one! % client components) + chans))))) + +(defn- create-session+logon! + [conn client session-id] + (when (and conn session-id (:connection-id @client)) + (let [nick (client-target client)] + @(d/transact conn + [{:db/ensure :papod.session/attrs + :db/id "new-session" + :papod.session/id session-id + :papod.session/nick nick + :papod.session/created-at (java.util.Date.)} + {:db/ensure :papod.logon/attrs + :papod.logon/id (java.util.UUID/randomUUID) + :papod.logon/session "new-session" + :papod.logon/connection [:papod.connection/id + (:connection-id @client)]}])))) + +(defn- maybe-register! + [client components] + (let [{:keys [nick user registered? cap-negotiating? w]} @client] + (cond + registered? [] + cap-negotiating? [] + (not nick) [] + (not user) [] + + (not (authenticate! client components)) + (do (swap! client assoc :quit? true) + [(numeric-reply client "464" ":Password incorrect") + "ERROR :Closing link: (Bad Password)"]) + + :else + (do ;; network-id is set at connect time from the PROXY v2 + ;; AUTHORITY (or PAPOD_NETWORK_NAME fallback); PASS may + ;; have overridden it explicitly. Either way, we assume + ;; it's set by now. + (swap! client assoc :registered? true) + (when-let [n (:n-unreg components)] + (swap! n (fn [v] (max 0 (dec v))))) + (when (:clients components) + (swap! (:clients components) + (fn [m] + (let [ex (get m nick) + ex-acct (some-> (:client-atom ex) + deref + :account) + same? (and (:account @client) + ex-acct + (= ex-acct + (:account + @client)))] + ;; A same-account session keeps the + ;; existing canonical entry so that + ;; nick-keyed PRIVMSG delivery does + ;; not get redirected. + (if same? + m + (assoc m nick + {:w w :client-atom client})))))) + (when-let [mx (:max-users components)] + (let [n (count + (filter + (fn [[_ m]] + (some-> (:client-atom m) deref + :registered?)) + @(:clients components)))] + (swap! mx max n))) + ;; Ensure member exists in the connection's network + (when-let [conn (:conn components)] + (when-let [net-id (:network-id @client)] + (let [db (d/db conn) + net-ref [:papod.network/id net-id] + existing (ffirst + (d/q '{:find [?m] + :in [$ ?net ?nick] + :where [[?m :papod.member/network ?net] + [?m :papod.member/nick ?nick]]} + db net-ref nick))] + (when-not existing + @(d/transact conn + [{:db/ensure :papod.member/attrs + :papod.member/id (java.util.UUID/randomUUID) + :papod.member/network net-ref + :papod.member/nick nick + :papod.member/status "active" + :papod.member/joined-at (java.util.Date.)}]))))) + (let [welcome [(numeric-reply client "001" + (str ":Welcome to the Internet Relay Chat Network " nick)) + (numeric-reply client "002" + (str ":Your host is " +server-name+ + ", running version " +version+)) + (numeric-reply client "003" + ":This server was created recently") + (numeric-reply client "004" + (str +server-name+ " " +version+ + " ioT" ;; user modes + " nt" ;; channel modes + )) + (numeric-reply client "005" + (str "ACCOUNTEXTBAN=a" + " AWAYLEN=200 BOT=B" + " CASEMAPPING=ascii" + " CHANLIMIT=#:100" + " CHANMODES=beI,k,l,Cnt" + " CHANNELLEN=64 CHANTYPES=#" + " ELIST= EXCEPTS=e" + " EXTBAN=~,am HOSTLEN=64" + " :are supported by this server")) + (numeric-reply client "005" + (str "INVEX=I KICKLEN=300" + " MAXLIST=b:50,e:50,I:50" + " MAXTARGETS=4 MODES=4" + " MONITOR=100 NETWORK=papod" + " NICKLEN=30 PREFIX=(ov)@+" + " STATUSMSG=@+" + " :are supported by this server")) + (numeric-reply client "005" + (str "TARGMAX=KICK:1,LIST:,WHOIS:1,PRIVMSG:4,NAMES:" + " TOPICLEN=390 USERLEN=18" + " UTF8ONLY WHOX" + " :are supported by this server")) + (numeric-reply client "422" + ":MOTD File is missing")] + memos (pending-memos (:conn components) nick) + memo-line (when (seq memos) + [(str ":MemoServ NOTICE " nick + " :You have " (count memos) + " unread memo(s)." + " Use /msg MemoServ LIST")]) + rejoins (auto-rejoin-replies client components) + pending (:pending-metadata @client) + meta-burst + (when (seq pending) + (let [bid (str (java.util.UUID/randomUUID)) + nlow (string/lower-case nick)] + (when-let [meta-atom (:metadata components)] + (swap! meta-atom update nlow + (fnil into {}) pending)) + (swap! client dissoc :pending-metadata) + (concat + [(str "BATCH +" bid " metadata " nick)] + (map + (fn [[k v]] + (str "@batch=" bid + " :" +server-name+ + " METADATA " nick " " k + " * :" v)) + (sort-by first pending)) + [(str "BATCH -" bid)])))] + (notify-monitors! components nick "730") + (vec (concat welcome memo-line meta-burst rejoins))))))) + +(defn- valid-nick? + [s] + (and (seq s) + (not= s "*") + (re-matches #"[A-Za-z\[\]\\`_^{|}][A-Za-z0-9\[\]\\`_^{|}\-]*" s))) + +(defn- handle-nick + [params client components] + (let [raw (first params) + new-nick (when raw + (cond-> raw + (string/starts-with? raw ":") (subs 1)))] + (when (and new-nick (not (:registered? @client))) + (swap! client assoc :attempted-nick new-nick)) + (cond + (or (empty? params) + (string/blank? new-nick)) + [(numeric-reply client "431" ":No nickname given")] + + (not (valid-nick? new-nick)) + [(numeric-reply client "432" + (str new-nick " :Erroneous nickname"))] + + ;; Nick already in use by another client + (and (:clients components) + (let [lower (string/lower-case new-nick)] + (when-let + [[existing-nick existing] + (first + (filter + (fn [[k _]] + (= (string/lower-case k) lower)) + @(:clients components)))] + (let [ca (:client-atom existing) + ex (when ca @ca) + old (:socket ex) + stale? (and ex + (not (:registered? ex)) + old) + same-account? + (and (:authenticated? @client) + (:account @client) + (:authenticated? ex) + (= (:account @client) + (:account ex)))] + (cond + ;; Same SASL account: allow shared nick + ;; (multi-session for the same identity) + same-account? false + ;; Both still mid-registration: ghost the + ;; stale one. + (and stale? + (not (:registered? @client))) + (do (try (.close old) + (catch Exception _)) + (swap! (:clients components) + dissoc existing-nick) + false) + :else + (not= existing-nick + (:nick @client)))))) + true) + [(numeric-reply client "433" + (str new-nick " :Nickname is already in use"))] + + (and (:registered? @client) + (= new-nick (:nick @client))) + [] + + :else + (if (:registered? @client) + ;; Post-registration nick change: broadcast + (let [old-nick (:nick @client) + {:keys [clients channels ops]} + components + line (str ":" old-nick " NICK " + new-nick)] + (swap! client assoc :nick new-nick) + ;; Update clients map + (when clients + (swap! clients dissoc old-nick) + (swap! clients assoc new-nick + {:w (:w @client) + :client-atom client})) + ;; Update channels membership + (when channels + (doseq [[ch members] @channels + :when (contains? members old-nick)] + (swap! channels update ch + #(-> % (disj old-nick) + (conj new-nick))))) + ;; Update ops + (when ops + (doseq [[ch op-set] @ops + :when (contains? op-set old-nick)] + (swap! ops update ch + #(-> % (disj old-nick) + (conj new-nick))))) + ;; Update voiced + (when-let [voiced (:voiced components)] + (doseq [[ch v-set] @voiced + :when (contains? v-set old-nick)] + (swap! voiced update ch + #(-> % (disj old-nick) + (conj new-nick))))) + ;; Broadcast to peers (skip self; we return [line]) + (let [notified (atom #{new-nick})] + (when (and clients channels) + (doseq [[ch members] @channels + :when (contains? members new-nick) + mn members + :let [m (get @clients mn)] + :when (and m + (not (contains? + @notified mn)))] + (deliver-to-client! (:w m) line) + (swap! notified conj mn)))) + ;; MONITOR notifications: only on real (non-case) + ;; nick changes — old goes offline, new comes + ;; online. + (when (not= (string/lower-case old-nick) + (string/lower-case new-nick)) + (notify-monitors! components old-nick "731") + (notify-monitors! components new-nick "730")) + [line]) + ;; Pre-registration: just set the nick + (do (swap! client assoc :nick new-nick) + (maybe-register! client components)))))) + +(defn- handle-user + [params client components] + (let [raw-realname (when (>= (count params) 4) + (string/join " " (drop 3 params))) + realname (when raw-realname + (cond-> raw-realname + (string/starts-with? raw-realname ":") + (subs 1)))] + (cond + (:registered? @client) + [(numeric-reply client "462" ":You may not reregister")] + + (or (< (count params) 4) + (string/blank? realname)) + [(numeric-reply client "461" "USER :Not enough parameters")] + + :else + (let [[username hostname servername] params] + (swap! client assoc :user {:username username + :hostname hostname + :servername servername + :realname realname}) + (maybe-register! client components))))) + +(defn- handle-ping + [params client] + (let [token (first params) + token (when token + (cond-> token + (string/starts-with? token ":") (subs 1)))] + (if (or (empty? params) (string/blank? token)) + [(numeric-reply client "409" ":No origin specified")] + [(str ":" +server-name+ " PONG " +server-name+ + " :" token)]))) + +(defconst- +sasl-mechanisms+ + "PLAIN,RESUME") + +(defn- handle-cap + [params client components] + (let [nick (client-target client) + subcmd (when (seq params) (string/upper-case (first params))) + args (string/join " " (rest params)) + args (cond-> args + (string/starts-with? args ":") (subs 1))] + (case subcmd + "LS" + (let [v302? (and (seq args) + (try (>= (Long/parseLong + (string/trim args)) + 302) + (catch Exception _ false))) + reg-vals (cond-> [] + +reg-before-connect?+ (conj "before-connect") + +reg-email-required?+ (conj "email-required")) + reg-cap (if (seq reg-vals) + (str "draft/account-registration=" + (string/join "," reg-vals)) + "draft/account-registration")] + (swap! client assoc :cap-negotiating? true) + [(str ":" +server-name+ " CAP " nick " LS :" + (if v302? + (str "sasl=" +sasl-mechanisms+ + " message-tags server-time echo-message" + " batch labeled-response multi-prefix" + " userhost-in-names extended-join" + " away-notify utf8only" + " account-notify account-tag chghost" + " setname invite-notify" + " extended-monitor" + " draft/multiline=max-bytes=4096" + ",max-lines=32" + " draft/read-marker" + " draft/message-redaction" + " draft/message-editing" + " draft/chathistory" + " draft/event-playback" + " draft/relaymsg " + reg-cap + " draft/channel-rename" + " draft/extended-isupport" + " draft/metadata-2=before-connect" + " draft/typing") + (str "sasl message-tags server-time" + " echo-message batch" + " labeled-response multi-prefix" + " userhost-in-names extended-join" + " away-notify utf8only" + " account-notify account-tag chghost" + " setname invite-notify" + " extended-monitor" + " draft/multiline draft/read-marker" + " draft/message-redaction" + " draft/message-editing" + " draft/chathistory" + " draft/event-playback" + " draft/relaymsg " + reg-cap + " draft/channel-rename" + " draft/extended-isupport" + " draft/metadata-2" + " draft/typing")))]) + + "REQ" + (let [raw-args (string/trim args) + requested (string/split raw-args #"\s+") + supported #{"sasl" "message-tags" "server-time" + "echo-message" "batch" + "labeled-response" "multi-prefix" + "userhost-in-names" "extended-join" + "away-notify" "utf8only" + "account-notify" "account-tag" + "chghost" "setname" "invite-notify" + "extended-monitor" + "draft/multiline" "draft/read-marker" + "draft/message-redaction" + "draft/message-editing" + "draft/chathistory" + "draft/event-playback" + "draft/relaymsg" + "draft/account-registration" + "draft/channel-rename" + "draft/extended-isupport" + "draft/metadata-2" + "draft/typing"} + split (fn [c] (if (string/starts-with? c "-") + [(subs c 1) :remove] + [c :add])) + split-reqs (map split requested) + all-known? (every? (fn [[name _]] + (supported name)) + split-reqs)] + (if-not all-known? + [(str ":" +server-name+ " CAP " nick + " NAK :" raw-args)] + (do (doseq [[name op] split-reqs] + (swap! client update :caps + (fn [s] + (case op + :add (conj (or s #{}) name) + :remove (disj (or s #{}) name))))) + [(str ":" +server-name+ " CAP " nick + " ACK :" raw-args)]))) + + "LIST" + [(str ":" +server-name+ " CAP " nick + " LIST :" (string/join " " (or (:caps @client) #{})))] + + "END" + (do (swap! client assoc :cap-negotiating? false) + (maybe-register! client components)) + + [(numeric-reply client "410" + (str (or subcmd "") " :Invalid CAP subcommand"))]))) + +(defn- handle-sasl-plain + [base64-data client components] + (let [nick (client-target client) + conn (cracha-conn components)] + (try + (let [decoded (String. + (.decode (java.util.Base64/getDecoder) ^String base64-data) + "UTF-8") + parts (string/split decoded #"\x00" -1) + authcid (second parts) + password (nth parts 2 nil) + result (when (and authcid password conn) + (cracha/login! conn authcid password "sasl"))] + (swap! client assoc :sasl-state nil) + (if (uuid? result) + (do (swap! client assoc :authenticated? true + :session-id result + :account authcid) + (create-session+logon! (:conn components) client result) + (when-let [ac (:account-clients components)] + (swap! ac update authcid (fnil conj #{}) + client)) + ;; account-notify broadcast (no-op on initial + ;; registration since the user hasn't joined + ;; any channel yet, but matters on SASL 3.2 + ;; re-auth mid-session). + (broadcast-account-notify! + components nick authcid) + [(numeric-reply client "900" + (str nick "!" nick "@localhost " authcid + " :You are now logged in as " authcid)) + (numeric-reply client "903" + ":SASL authentication successful")]) + [(numeric-reply client "904" + ":SASL authentication failed")])) + (catch Exception _ + (swap! client assoc :sasl-state nil) + [(numeric-reply client "904" ":SASL authentication failed")])))) + +(defn- handle-sasl-resume + [base64-data client components] + (let [nick (client-target client) + conn (:conn components)] + (try + (let [decoded (String. + (.decode (java.util.Base64/getDecoder) ^String base64-data) + "UTF-8") + session-id (java.util.UUID/fromString (string/trim decoded)) + db (when conn (d/db conn)) + session-eid (when db + (ffirst + (d/q '{:find [?s] + :in [$ ?id] + :where [[?s :papod.session/id ?id]]} + db session-id))) + session (when session-eid (d/entity db session-eid)) + valid? (and session + (= nick (:papod.session/nick session)) + (nil? (:papod.session/finished-at session)))] + (swap! client assoc :sasl-state nil) + (if valid? + (do (swap! client assoc :authenticated? true + :session-id session-id) + ;; Create logon for resumed session + (when (:connection-id @client) + @(d/transact conn + [{:db/ensure :papod.logon/attrs + :papod.logon/id (java.util.UUID/randomUUID) + :papod.logon/session session-eid + :papod.logon/connection [:papod.connection/id + (:connection-id @client)]}])) + [(numeric-reply client "900" + (str nick "!" nick "@localhost " nick + " :You are now logged in as " nick)) + (numeric-reply client "903" + ":SASL authentication successful")]) + [(numeric-reply client "904" + ":SASL authentication failed")])) + (catch Exception _ + (swap! client assoc :sasl-state nil) + [(numeric-reply client "904" ":SASL authentication failed")])))) + +(defn- handle-authenticate + [params client components] + (let [arg (first params)] + (cond + (not (contains? (or (:caps @client) #{}) "sasl")) + [(numeric-reply client "904" ":SASL authentication failed")] + + (= "*" arg) + (do (swap! client assoc :sasl-state nil) + [(numeric-reply client "906" ":SASL authentication aborted")]) + + (nil? (:sasl-state @client)) + (let [mechanism (some-> arg string/upper-case)] + (case mechanism + "PLAIN" + (do (swap! client assoc :sasl-state :authenticating + :sasl-mechanism "PLAIN") + ["AUTHENTICATE +"]) + "RESUME" + (do (swap! client assoc :sasl-state :authenticating + :sasl-mechanism "RESUME") + ["AUTHENTICATE +"]) + [(numeric-reply client "908" + (str +sasl-mechanisms+ " :are available SASL mechanisms")) + (numeric-reply client "904" ":SASL authentication failed")])) + + (= :authenticating (:sasl-state @client)) + (let [buf (or (:sasl-buffer @client) "") + chunk (or arg "") + chunk-too-long? (> (count chunk) 400) + new-buf (cond + (= "+" chunk) buf + :else (str buf chunk)) + buf-too-long? (> (count new-buf) 8192) + done? (or (= "+" chunk) + (< (count chunk) 400)) + mechanism (:sasl-mechanism @client)] + (cond + (or chunk-too-long? buf-too-long?) + (do (swap! client assoc :sasl-state nil + :sasl-mechanism nil + :sasl-buffer nil) + [(numeric-reply client "905" + ":SASL message too long")]) + + (not done?) + (do (swap! client assoc :sasl-buffer new-buf) + []) + + :else + (do (swap! client assoc :sasl-mechanism nil + :sasl-buffer nil) + (case mechanism + "PLAIN" (handle-sasl-plain new-buf client components) + "RESUME" (handle-sasl-resume new-buf client components)))))))) + +(defn- nickserv-notice + [client text] + (str ":NickServ NOTICE " (client-target client) " :" text)) + +(defn- nickserv-register + [args client components] + (let [nick (:nick @client) + cracha (:cracha components) + conn (cracha-conn components)] + (cond + (not cracha) + [(nickserv-notice client "Registration is not available")] + + (empty? args) + [(nickserv-notice client "Usage: REGISTER <password>")] + + :else + (let [password (first args) + existing (cracha/user-by-email conn nick)] + (if existing + [(nickserv-notice client "Nick is already registered")] + (let [salt (str (java.util.UUID/randomUUID)) + user-id (cracha/register! cracha nick salt password) + token (ffirst + (d/q '{:find [?token] + :in [$ ?uid] + :where [[?u :cracha.user/id ?uid] + [?a :cracha.confirmation-attempt/user ?u] + [?a :cracha.confirmation-attempt/token ?token]]} + (d/db conn) user-id))] + (cracha/confirm! conn token) + [(nickserv-notice client "Nick registered successfully")])))))) + +(defn- nickserv-identify + [args client components] + (let [nick (:nick @client) + conn (cracha-conn components)] + (cond + (not conn) + [(nickserv-notice client "Identification is not available")] + + (empty? args) + [(nickserv-notice client "Usage: IDENTIFY <password>")] + + :else + (let [result (cracha/login! conn nick (first args) "nickserv")] + (case result + :unknown-user + [(nickserv-notice client "Nick is not registered")] + :bad-credentials + [(nickserv-notice client "Invalid password")] + :unconfirmed-user + [(nickserv-notice client "Nick is not confirmed")] + (do (swap! client assoc :identified? true + :session-id result) + (create-session+logon! (:conn components) client result) + [(nickserv-notice client "You are now identified") + (nickserv-notice client (str "Session ID: " result))])))))) + +(defn- handle-nickserv + [content client components] + (let [text (cond-> content + (string/starts-with? content ":") (subs 1)) + parts (string/split (string/trim text) #"\s+") + cmd (when (seq parts) (string/upper-case (first parts))) + args (rest parts)] + (case cmd + "REGISTER" (nickserv-register args client components) + "IDENTIFY" (nickserv-identify args client components) + [(nickserv-notice client + (str "Unknown command. Valid commands: REGISTER, IDENTIFY"))]))) + +(defn- chanserv-notice + [client text] + (str ":ChanServ NOTICE " (client-target client) " :" text)) + +(defn- chanserv-register + [args client components] + (let [conn (:conn components) + nick (client-target client) + channel-name (first args)] + (cond + (empty? args) + [(chanserv-notice client "Usage: REGISTER #channel")] + + (not conn) + [(chanserv-notice client "Channel registration is not available")] + + :else + (let [db (d/db conn) + channel-eid (resolve-channel db channel-name)] + (cond + (nil? channel-eid) + [(chanserv-notice client (str channel-name " does not exist"))] + + (:papod.channel/owner (d/entity db channel-eid)) + [(chanserv-notice client (str channel-name " is already registered"))] + + :else + (do @(d/transact conn + [[:db/add channel-eid :papod.channel/owner nick] + {:db/ensure :papod.access/attrs + :papod.access/id (java.util.UUID/randomUUID) + :papod.access/channel channel-eid + :papod.access/nick nick + :papod.access/level "owner"}]) + [(chanserv-notice client + (str channel-name " has been registered"))])))))) + +(defn- chanserv-set + [args client components] + (let [conn (:conn components) + nick (client-target client)] + (cond + (< (count args) 3) + [(chanserv-notice client "Usage: SET #channel <TOPIC|DESCRIPTION> <value>")] + + :else + (let [[channel-name setting & value-parts] args + value (string/join " " value-parts) + db (d/db conn) + channel-eid (resolve-channel db channel-name)] + (cond + (nil? channel-eid) + [(chanserv-notice client (str channel-name " does not exist"))] + + (not (has-access? db channel-eid nick #{"owner" "op"})) + [(chanserv-notice client "Permission denied")] + + :else + (case (string/upper-case setting) + "TOPIC" + (do @(d/transact conn + [[:db/add channel-eid :papod.channel/topic value]]) + [(chanserv-notice client + (str "Topic for " channel-name " set"))]) + "DESCRIPTION" + (do @(d/transact conn + [[:db/add channel-eid :papod.channel/description value]]) + [(chanserv-notice client + (str "Description for " channel-name " set"))]) + [(chanserv-notice client + "Unknown setting. Use TOPIC or DESCRIPTION")])))))) + +(defn- chanserv-info + [args client components] + (let [conn (:conn components) + channel-name (first args)] + (cond + (empty? args) + [(chanserv-notice client "Usage: INFO #channel")] + + :else + (let [db (d/db conn) + channel-eid (resolve-channel db channel-name)] + (if-not channel-eid + [(chanserv-notice client (str channel-name " does not exist"))] + (let [entity (d/entity db channel-eid)] + [(chanserv-notice client (str "Channel: " channel-name)) + (chanserv-notice client + (str "Owner: " + (or (:papod.channel/owner entity) "(unregistered)"))) + (chanserv-notice client + (str "Topic: " + (or (:papod.channel/topic entity) "(not set)"))) + (chanserv-notice client + (str "Description: " + (or (:papod.channel/description entity) "(not set)")))])))))) + +(defn- chanserv-op + [args client components] + (let [conn (:conn components) + nick (client-target client) + [channel-name target-nick] args] + (cond + (< (count args) 2) + [(chanserv-notice client "Usage: OP #channel <nick>")] + + :else + (let [db (d/db conn) + channel-eid (resolve-channel db channel-name)] + (cond + (nil? channel-eid) + [(chanserv-notice client (str channel-name " does not exist"))] + + (not (has-access? db channel-eid nick #{"owner"})) + [(chanserv-notice client "Permission denied")] + + (has-access? db channel-eid target-nick #{"op"}) + [(chanserv-notice client + (str target-nick " is already an operator"))] + + :else + (do @(d/transact conn + [{:db/ensure :papod.access/attrs + :papod.access/id (java.util.UUID/randomUUID) + :papod.access/channel channel-eid + :papod.access/nick target-nick + :papod.access/level "op"} + [:papod.access/ensure-unique channel-eid target-nick "op"]]) + [(chanserv-notice client + (str target-nick " is now an operator of " + channel-name))])))))) + +(defn- chanserv-deop + [args client components] + (let [conn (:conn components) + nick (client-target client) + [channel-name target-nick] args] + (cond + (< (count args) 2) + [(chanserv-notice client "Usage: DEOP #channel <nick>")] + + :else + (let [db (d/db conn) + channel-eid (resolve-channel db channel-name)] + (cond + (nil? channel-eid) + [(chanserv-notice client (str channel-name " does not exist"))] + + (not (has-access? db channel-eid nick #{"owner"})) + [(chanserv-notice client "Permission denied")] + + :else + (let [access-eid (ffirst + (d/q '{:find [?a] + :in [$ ?ch ?n] + :where [[?a :papod.access/channel ?ch] + [?a :papod.access/nick ?n] + [?a :papod.access/level "op"]]} + db channel-eid target-nick))] + (if-not access-eid + [(chanserv-notice client + (str target-nick " is not an operator"))] + (do @(d/transact conn [[:db/retractEntity access-eid]]) + [(chanserv-notice client + (str target-nick " is no longer an operator of " + channel-name))])))))))) + +(defn- chanserv-access + [args client components] + (let [conn (:conn components) + nick (client-target client) + [channel-name subcmd & rest-args] args] + (cond + (< (count args) 2) + [(chanserv-notice client + "Usage: ACCESS #channel <LIST|ADD|DEL> [args]")] + + :else + (let [db (d/db conn) + channel-eid (resolve-channel db channel-name) + subcmd (string/upper-case subcmd)] + (cond + (nil? channel-eid) + [(chanserv-notice client (str channel-name " does not exist"))] + + (and (not= subcmd "LIST") + (not (has-access? db channel-eid nick #{"owner"}))) + [(chanserv-notice client "Permission denied")] + + :else + (case subcmd + "LIST" + (let [entries (d/q '{:find [?n ?l] + :in [$ ?ch] + :where [[?a :papod.access/channel ?ch] + [?a :papod.access/nick ?n] + [?a :papod.access/level ?l]]} + db channel-eid)] + (if (empty? entries) + [(chanserv-notice client + (str "No access entries for " channel-name))] + (mapv (fn [[n l]] + (chanserv-notice client (str n " - " l))) + entries))) + + "ADD" + (if (< (count rest-args) 2) + [(chanserv-notice client + "Usage: ACCESS #channel ADD <nick> <level>")] + (let [[target-nick level] rest-args + level (string/lower-case level)] + (if-not (#{"op" "voice" "ban"} level) + [(chanserv-notice client + "Level must be op, voice, or ban")] + (do @(d/transact conn + [{:db/ensure :papod.access/attrs + :papod.access/id (java.util.UUID/randomUUID) + :papod.access/channel channel-eid + :papod.access/nick target-nick + :papod.access/level level} + [:papod.access/ensure-unique channel-eid target-nick level]]) + [(chanserv-notice client + (str target-nick " added with level " level))])))) + + "DEL" + (if (empty? rest-args) + [(chanserv-notice client + "Usage: ACCESS #channel DEL <nick>")] + (let [target-nick (first rest-args) + eids (d/q '{:find [?a] + :in [$ ?ch ?n] + :where [[?a :papod.access/channel ?ch] + [?a :papod.access/nick ?n] + [?a :papod.access/level ?l] + [(not= ?l "owner")]]} + db channel-eid target-nick)] + (if (empty? eids) + [(chanserv-notice client + (str target-nick " has no access entries"))] + (do @(d/transact conn + (mapv (fn [[eid]] [:db/retractEntity eid]) eids)) + [(chanserv-notice client + (str target-nick + " removed from access list"))])))) + + [(chanserv-notice client + "Unknown subcommand. Use LIST, ADD, or DEL")])))))) + +(defn- chanserv-kick + [args client components] + (let [{:keys [conn clients channels]} components + nick (client-target client) + [channel-name target-nick & reason-parts] args + reason (if (seq reason-parts) + (string/join " " reason-parts) + "Kicked")] + (cond + (< (count args) 2) + [(chanserv-notice client "Usage: KICK #channel <nick> [reason]")] + + :else + (let [db (d/db conn) + channel-eid (resolve-channel db channel-name)] + (cond + (nil? channel-eid) + [(chanserv-notice client (str channel-name " does not exist"))] + + (not (has-access? db channel-eid nick #{"owner" "op"})) + [(chanserv-notice client "Permission denied")] + + (not (contains? (get @channels channel-name) target-nick)) + [(chanserv-notice client + (str target-nick " is not in " channel-name))] + + :else + (do + ;; Persist kick event + (let [chan-uuid (:papod.channel/id + (d/entity db channel-eid))] + @(d/transact conn + [{:db/ensure :papod.event/attrs + :db/id "new-event" + :papod.event/id (java.util.UUID/randomUUID) + :papod.event/channel channel-eid + :papod.event/type "user-kick" + :papod.event/source-nick nick + :papod.event/payload (str target-nick " " reason) + :papod.event/created-at (java.util.Date.)} + [:papod.event/add-seq "new-event" chan-uuid]])) + ;; Remove from channel + (swap! channels update channel-name disj target-nick) + ;; Notify channel + kicked user + (let [line (str ":ChanServ KICK " channel-name + " " target-nick " :" reason)] + (when clients + (doseq [mn (conj (get @channels channel-name) + target-nick) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line)))) + [])))))) + +(defn- chanserv-ban + [args client components] + (let [conn (:conn components) + nick (client-target client) + [channel-name target-nick] args] + (cond + (< (count args) 2) + [(chanserv-notice client "Usage: BAN #channel <nick>")] + + :else + (let [db (d/db conn) + channel-eid (resolve-channel db channel-name)] + (cond + (nil? channel-eid) + [(chanserv-notice client (str channel-name " does not exist"))] + + (not (has-access? db channel-eid nick #{"owner" "op"})) + [(chanserv-notice client "Permission denied")] + + (has-access? db channel-eid target-nick #{"ban"}) + [(chanserv-notice client + (str target-nick " is already banned"))] + + :else + (do @(d/transact conn + [{:db/ensure :papod.access/attrs + :papod.access/id (java.util.UUID/randomUUID) + :papod.access/channel channel-eid + :papod.access/nick target-nick + :papod.access/level "ban"} + [:papod.access/ensure-unique channel-eid target-nick "ban"]]) + [(chanserv-notice client + (str target-nick " has been banned from " + channel-name))])))))) + +(defn- chanserv-invite + [args client components] + (let [{:keys [conn clients]} components + nick (client-target client) + [channel-name target-nick] args] + (cond + (< (count args) 2) + [(chanserv-notice client "Usage: INVITE #channel <nick>")] + + :else + (let [db (d/db conn) + channel-eid (resolve-channel db channel-name)] + (cond + (nil? channel-eid) + [(chanserv-notice client (str channel-name " does not exist"))] + + (not (has-access? db channel-eid nick #{"owner" "op"})) + [(chanserv-notice client "Permission denied")] + + :else + (do + (when-let [m (and clients (get @clients target-nick))] + (deliver-to-client! (:w m) + (str ":" nick " INVITE " target-nick " " channel-name))) + [(chanserv-notice client + (str target-nick " has been invited to " + channel-name))])))))) + +(defn- handle-chanserv + [content client components] + (let [text (cond-> content + (string/starts-with? content ":") (subs 1)) + parts (string/split (string/trim text) #"\s+") + cmd (when (seq parts) (string/upper-case (first parts))) + args (vec (rest parts))] + (case cmd + "REGISTER" (chanserv-register args client components) + "SET" (chanserv-set args client components) + "INFO" (chanserv-info args client components) + "OP" (chanserv-op args client components) + "DEOP" (chanserv-deop args client components) + "ACCESS" (chanserv-access args client components) + "KICK" (chanserv-kick args client components) + "BAN" (chanserv-ban args client components) + "INVITE" (chanserv-invite args client components) + [(chanserv-notice client + (str "Unknown command. Valid: REGISTER, SET, INFO, " + "OP, DEOP, ACCESS, KICK, BAN, INVITE"))]))) + +(defn- memoserv-notice + [client text] + (str ":MemoServ NOTICE " (client-target client) " :" text)) + +(defn- memoserv-send + [args client components] + (let [conn (:conn components) + nick (client-target client)] + (cond + (< (count args) 2) + [(memoserv-notice client "Usage: SEND <nick> <message>")] + + (not conn) + [(memoserv-notice client "Memo service is not available")] + + :else + (let [[target-nick & msg-parts] args + content (string/join " " msg-parts)] + @(d/transact conn + [{:db/ensure :papod.memo/attrs + :papod.memo/id (java.util.UUID/randomUUID) + :papod.memo/from nick + :papod.memo/to target-nick + :papod.memo/content content + :papod.memo/created-at (java.util.Date.) + :papod.memo/read? false}]) + [(memoserv-notice client (str "Memo sent to " target-nick))])))) + +(defn- memoserv-list + [client components] + (let [conn (:conn components) + nick (client-target client)] + (if-not conn + [(memoserv-notice client "Memo service is not available")] + (let [db (d/db conn) + memos (sort-by + (fn [[_ _ t]] t) + (d/q '{:find [?id ?from ?time ?read] + :in [$ ?nick] + :where [[?m :papod.memo/id ?id] + [?m :papod.memo/to ?nick] + [?m :papod.memo/from ?from] + [?m :papod.memo/created-at ?time] + [?m :papod.memo/read? ?read]]} + db nick))] + (if (empty? memos) + [(memoserv-notice client "No memos")] + (mapv (fn [[id from _ read]] + (memoserv-notice client + (str (if read "[read] " "[unread] ") + "from " from " - " id))) + memos)))))) + +(defn- memoserv-read + [args client components] + (let [conn (:conn components) + nick (client-target client)] + (cond + (empty? args) + [(memoserv-notice client "Usage: READ <id>")] + + :else + (let [memo-id (try (java.util.UUID/fromString (first args)) + (catch Exception _ nil))] + (if-not memo-id + [(memoserv-notice client "Invalid memo ID")] + (let [db (d/db conn) + memo-eid (ffirst + (d/q '{:find [?m] + :in [$ ?id ?nick] + :where [[?m :papod.memo/id ?id] + [?m :papod.memo/to ?nick]]} + db memo-id nick))] + (if-not memo-eid + [(memoserv-notice client "Memo not found")] + (let [entity (d/entity db memo-eid)] + @(d/transact conn + [[:db/add memo-eid :papod.memo/read? true]]) + [(memoserv-notice client + (str "From " (:papod.memo/from entity) ": " + (:papod.memo/content entity)))])))))))) + +(defn- memoserv-delete + [args client components] + (let [conn (:conn components) + nick (client-target client)] + (cond + (empty? args) + [(memoserv-notice client "Usage: DELETE <id>")] + + :else + (let [memo-id (try (java.util.UUID/fromString (first args)) + (catch Exception _ nil))] + (if-not memo-id + [(memoserv-notice client "Invalid memo ID")] + (let [db (d/db conn) + eid (ffirst + (d/q '{:find [?m] + :in [$ ?id ?nick] + :where [[?m :papod.memo/id ?id] + [?m :papod.memo/to ?nick]]} + db memo-id nick))] + (if-not eid + [(memoserv-notice client "Memo not found")] + (do @(d/transact conn [[:db/retractEntity eid]]) + [(memoserv-notice client "Memo deleted")])))))))) + +(defn- handle-memoserv + [content client components] + (let [text (cond-> content + (string/starts-with? content ":") (subs 1)) + parts (string/split (string/trim text) #"\s+") + cmd (when (seq parts) (string/upper-case (first parts))) + args (vec (rest parts))] + (case cmd + "SEND" (memoserv-send args client components) + "LIST" (memoserv-list client components) + "READ" (memoserv-read args client components) + "DELETE" (memoserv-delete args client components) + [(memoserv-notice client + "Unknown command. Valid: SEND, LIST, READ, DELETE")]))) + +(defn- find-event-eid + [db event-id] + (when event-id + (ffirst + (d/q '{:find [?e] + :in [$ ?id] + :where [[?e :papod.event/id ?id]]} + db event-id)))) + +(defn- handle-privmsg + ([params client components] + (handle-privmsg params nil client components)) + ([params tags client components] + (let [{:keys [conn clients channels]} components + nick (client-target client)] + (cond + (empty? params) + [(numeric-reply client "411" ":No recipient given (PRIVMSG)")] + + (or (< (count params) 2) + (let [text (string/join " " (rest params))] + (or (string/blank? text) + (= ":" text)))) + [(numeric-reply client "412" ":No text to send")] + + ;; Services: handle WITHOUT persisting + (= "NickServ" (first params)) + (handle-nickserv (string/join " " (rest params)) client components) + + (= "ChanServ" (first params)) + (handle-chanserv (string/join " " (rest params)) client components) + + (= "MemoServ" (first params)) + (handle-memoserv (string/join " " (rest params)) client components) + + :else + (let [raw-target (first params) + status-prefix (statusmsg-prefix raw-target) + target (if status-prefix + (subs raw-target 1) + raw-target) + content (string/join " " (rest params)) + msg-id (java.util.UUID/randomUUID) + msg-at (java.util.Date.) + chan? (and channels (channel-handle? target)) + body (cond-> content + (string/starts-with? content ":") (subs 1)) + ctcp? (and (seq body) + (= \u0001 (first body))) + action? (and ctcp? + (string/starts-with? body "ACTION")) + chmodes (or (when (:chan-modes components) + (get @(:chan-modes components) target)) + "+nt") + ;; IRCv3 +reply tag for threads + reply-id (when-let [r (get tags "+reply")] + (try (java.util.UUID/fromString r) + (catch Exception _ nil)))] + (cond + (and chan? (empty? (get @channels target))) + [(numeric-reply client "403" + (str target " :No such channel"))] + + (and (not chan?) clients + (not (get @clients target))) + [(numeric-reply client "401" + (str target " :No such nick/channel"))] + + ;; +n: no external messages + (and chan? + (string/includes? chmodes "n") + (not (contains? + (get @channels target) nick))) + [(numeric-reply client "404" + (str target + " :Cannot send to channel"))] + + ;; +m: moderated — only ops/voiced may speak + (and chan? + (string/includes? chmodes "m") + (not (and (:ops components) + (contains? + (get @(:ops components) target) + nick))) + (not (and (:voiced components) + (contains? + (get @(:voiced components) target) + nick)))) + [(numeric-reply client "404" + (str target + " :Cannot send to channel"))] + + ;; +C: no CTCP (except ACTION) + (and chan? ctcp? (not action?) + (string/includes? chmodes "C")) + [(numeric-reply client "404" + (str target + " :Cannot send CTCP to channel (+C)"))] + + ;; ~m: mute extban — sender is muted by a +b mask + ;; (ops/voiced bypass). Excepted by matching +e. + (and chan? + (not (and (:ops components) + (contains? + (get @(:ops components) target) + nick))) + (not (and (:voiced components) + (contains? + (get @(:voiced components) target) + nick))) + (let [bans-atom (:chan-bans components) + excepts-atom (:chan-excepts components) + bans (when bans-atom + (keys (get @bans-atom target {}))) + excepts (when excepts-atom + (keys (get @excepts-atom target {}))) + acct (:account @client)] + (and (seq bans) + (mute-matches? components bans nick) + (not (and (seq excepts) + (mute-except-matches? + components excepts nick acct)))))) + [(numeric-reply client "404" + (str target + " :Cannot send to channel (+b)"))] + + :else + (do + ;; PERSIST FIRST + (when conn + (let [db (d/db conn) + channel-eid (when chan? (resolve-channel db target)) + chan-uuid (when channel-eid + (:papod.channel/id + (d/entity db channel-eid))) + reply-eid (when reply-id + (find-event-eid db reply-id)) + src-acct (when-not chan? + (:account @client)) + tgt-acct (when-not chan? + (nick-account components target))] + @(d/transact conn + (cond-> [{:db/ensure :papod.event/attrs + :db/id "new-event" + :papod.event/id msg-id + :papod.event/type "user-message" + :papod.event/source-nick nick + :papod.event/payload content + :papod.event/created-at msg-at}] + reply-eid + (-> (update 0 assoc :papod.event/reply-to reply-eid)) + chan? + (-> (update 0 assoc :papod.event/channel channel-eid) + (conj [:papod.event/add-seq "new-event" chan-uuid])) + (not chan?) + (-> (update 0 assoc + :papod.event/target-nick target)) + src-acct + (-> (update 0 assoc + :papod.event/source-account + src-acct)) + tgt-acct + (-> (update 0 assoc + :papod.event/target-account + tgt-acct)))))) + ;; DELIVER + (let [client-tag-pairs + (->> (or tags {}) + (filter (fn [[k _]] + (and (string/starts-with? k "+") + (not= k "+reply")))) + (sort-by first)) + out-tags (cond-> [] + reply-id + (conj (str "+reply=" reply-id)) + (:bot? @client) + (conj "bot") + (seq client-tag-pairs) + (into (map (fn [[k v]] + (str k "=" v)) + client-tag-pairs))) + sender-acct (:account @client) + acct-tag (str "account=" (or sender-acct "*")) + delivery-target (or raw-target target) + raw (str ":" nick " PRIVMSG " + delivery-target " " content) + tagged (tag-line-at msg-id msg-at out-tags + raw) + tagged-acct (tag-line-at msg-id msg-at + (conj out-tags acct-tag) + raw) + ;; server-time only: msgid + time, no + ;; forwarded client-only tags + time-only (tag-line-at msg-id msg-at [] raw) + time-only-acct (tag-line-at msg-id msg-at + [acct-tag] raw) + pick-line + (fn [caps] + (let [mt? (and caps (caps "message-tags")) + st? (and caps (caps "server-time")) + at? (and caps (caps "account-tag"))] + (cond + mt? (if at? tagged-acct tagged) + st? (if at? time-only-acct time-only) + at? (str "@" acct-tag " " raw) + :else raw))) + status-ok? (fn [mn] + (case status-prefix + "@" (and (:ops components) + (contains? + (get @(:ops components) + target) + mn)) + "+" (or (and (:ops components) + (contains? + (get @(:ops components) + target) + mn)) + (and (:voiced components) + (contains? + (get @(:voiced components) + target) + mn))) + true)) + text (cond-> content + (string/starts-with? content ":") + (subs 1)) + ctcp? (and (seq text) + (= \u0001 (first text))) + deliver! (fn [m] + (let [ca (:client-atom m) + caps (when ca + (or (:caps @ca) #{})) + line (pick-line caps) + no-ctcp? (and ca + (:no-ctcp? @ca))] + (when-not (and ctcp? no-ctcp? + (not chan?)) + (deliver-to-client! + (:w m) line))))] + (if chan? + (doseq [mn (get @channels target) + :when (and (not= mn nick) + (status-ok? mn)) + :let [m (when clients + (get @clients mn))] + :when m] + (deliver! m)) + (when-let [m (and clients + (get @clients target))] + (deliver! m))) + ;; Build reply vector + (let [my-caps (or (:caps @client) #{}) + echo? (contains? my-caps "echo-message") + echo (when echo? + [(pick-line my-caps)]) + away (when-not chan? + (let [m (when clients + (get @clients target)) + ca (when m (:client-atom m)) + a (when ca (:away @ca))] + (when a + [(numeric-reply client "301" + (str target " :" a))])))] + (vec (concat echo away))))))))))) + +(defn- is-op? + [components handle nick] + (when-let [ops (:ops components)] + (contains? (get @ops handle) nick))) + +(defn- names-for + [components handle & {:keys [multi-prefix? userhost? members]}] + (let [members (or members + (when (:channels components) + (get @(:channels components) handle))) + ops (:ops components) + voiced (:voiced components)] + (when (seq members) + (string/join + " " + (map (fn [mn] + (let [op? (and ops + (contains? + (get @ops handle) mn)) + voc? (and voiced + (contains? + (get @voiced handle) + mn)) + body (if userhost? + (nuh-for-nick components mn) + mn)] + (cond + (and op? voc? multi-prefix?) + (str "@+" body) + op? (str "@" body) + voc? (str "+" body) + :else body))) + members))))) + +(defn- chan-modes-for + [components handle] + (or (when (:chan-modes components) + (get @(:chan-modes components) handle)) + "+nt")) + +(defn- glob-pattern + [^String mask] + (re-pattern + (str "^" + (->> mask + (map (fn [c] + (case c + \* ".*" + \? "." + (java.util.regex.Pattern/quote + (str c))))) + (apply str)) + "$"))) + +(defn- nuh-for-nick + "Returns nick!user@host for a given nick, looking up the live client's + user/host when available. Falls back to nick!nick@localhost." + [components nick] + (or (when-let [clients (:clients components)] + (when-let [m (get @clients nick)] + (when-let [c (some-> (:client-atom m) deref)] + (let [u (:user c) + ident (or (:username u) nick) + host (or (:host c) "localhost")] + (str nick "!" ident "@" host))))) + (str nick "!" nick "@localhost"))) + +(defn- nick-mask-match? + [mask components nick] + (boolean + (re-matches (glob-pattern + (string/lower-case mask)) + (string/lower-case (nuh-for-nick components nick))))) + +(defn- ban-matches? + "Matches a +b/+e mask set against a join attempt. Recognises + ~a:account extbans (match by account) and ignores ~m:mute + extbans (those filter PRIVMSGs, not joins)." + ([components masks nick] + (ban-matches? components masks nick nil)) + ([components masks nick account] + (some (fn [mask] + (cond + (string/starts-with? mask "~a:") + (and account + (= (string/lower-case + (subs mask 3)) + (string/lower-case account))) + (string/starts-with? mask "~m:") + false + :else + (nick-mask-match? mask components nick))) + masks))) + +(defn- mute-matches? + "Matches the ~m:<nick-mask> extban subset of a +b list." + [components masks nick] + (some (fn [mask] + (and (string/starts-with? mask "~m:") + (nick-mask-match? (subs mask 3) components nick))) + masks)) + +(defn- mute-except-matches? + "Matches an entry in the +e list against a muted user. Recognises + ~m:<mask> (mute exception by nick mask), ~a:<account> (account-based + exception), or a plain nick mask." + [components masks nick account] + (some (fn [mask] + (cond + (string/starts-with? mask "~m:") + (nick-mask-match? (subs mask 3) components nick) + (string/starts-with? mask "~a:") + (and account + (= (string/lower-case (subs mask 3)) + (string/lower-case account))) + :else + (nick-mask-match? mask components nick))) + masks)) + +(defn- chan-symbol + [components handle] + (let [m (chan-modes-for components handle)] + (cond + (string/includes? m "s") "@" + (string/includes? m "p") "*" + :else "="))) + +(defn- join-one! + ([handle client components] + (join-one! handle nil client components)) + ([raw-handle key-arg client components] + (let [handle (when raw-handle + (string/lower-case raw-handle)) + {:keys [conn clients channels]} components + nick (client-target client) + modes (chan-modes-for components handle) + existing? (and channels (seq (get @channels handle))) + invited? (when-let [invites (:invites components)] + (contains? + (get @invites handle #{}) nick)) + keys-map (some-> components :chan-keys deref) + limits (some-> components :chan-limits deref) + req-key (when keys-map (get keys-map handle)) + lim (when limits (get limits handle)) + full? (and lim + (>= (count (get @channels handle)) + lim))] + (cond + ;; Validate channel name + (not (channel-handle? handle)) + [(numeric-reply client "403" + (str handle " :No such channel"))] + + ;; Private channels: & prefix requires existing channel + membership + (and (string/starts-with? handle "&") + (let [db (when conn (d/db conn)) + eid (when db (resolve-channel db handle))] + (or (nil? eid) + (not (has-access? db eid nick + #{"owner" "op" "voice"}))))) + [(numeric-reply client "403" + (str handle " :No such channel"))] + + ;; +i (invite-only): require invite (bypassed by +I match) + (and existing? + (not (contains? (get @channels handle) nick)) + (string/includes? modes "i") + (not invited?) + (let [invex-atom (:chan-invex components) + invex (when invex-atom + (keys (get @invex-atom handle {})))] + (not (and (seq invex) + (ban-matches? + components invex nick (:account @client)))))) + [(numeric-reply client "473" + (str handle + " :Cannot join channel (+i)"))] + + ;; +k (key): require correct key + (and existing? + (not (contains? (get @channels handle) nick)) + req-key (not= req-key key-arg)) + [(numeric-reply client "475" + (str handle + " :Cannot join channel (+k)"))] + + ;; +l (limit): channel is full + (and existing? full? + (not (contains? (get @channels handle) nick))) + [(numeric-reply client "471" + (str handle + " :Cannot join channel (+l)"))] + + ;; +b: banned (INVITE or matching +e exception bypasses) + (and existing? + (not (contains? (get @channels handle) nick)) + (not invited?) + (let [bans-atom (:chan-bans components) + excepts-atom (:chan-excepts components) + bans (when bans-atom + (keys (get @bans-atom handle {}))) + excepts (when excepts-atom + (keys (get @excepts-atom handle {}))) + acct (:account @client)] + (and (seq bans) + (ban-matches? components bans nick acct) + (not (and (seq excepts) + (ban-matches? + components excepts nick acct)))))) + [(numeric-reply client "474" + (str handle " :Cannot join channel (+b)"))] + + :else + (let [db (when conn (d/db conn)) + channel-eid (when db (resolve-channel db handle)) + banned? (and channel-eid + (has-access? db channel-eid nick #{"ban"})) + new-id (java.util.UUID/randomUUID) + chan-uuid (if channel-eid + (:papod.channel/id (d/entity db channel-eid)) + new-id) + channel-ref (or channel-eid "new-channel")] + (cond + banned? + [(numeric-reply client "474" + (str handle " :Cannot join channel (+b)"))] + + :else + (do + ;; Consume invite if any + (when-let [invites (:invites components)] + (swap! invites update handle + (fnil disj #{}) nick)) + ;; Reset stale chan-modes/keys/limits if + ;; channel is empty (no live members). + (when-not existing? + (when (:chan-modes components) + (swap! (:chan-modes components) + dissoc handle)) + (when (:chan-keys components) + (swap! (:chan-keys components) + dissoc handle)) + (when (:chan-limits components) + (swap! (:chan-limits components) + dissoc handle)) + (when (:invites components) + (swap! (:invites components) + dissoc handle))) + ;; PERSIST — single transaction. Retry once on + ;; conflict (concurrent JOIN that created the channel). + (let [event-id (java.util.UUID/randomUUID) + already? (and conn channel-eid + (seq + (d/q '{:find [?m] + :in [$ ?ch ?nick] + :where [[?m :papod.membership/channel ?ch] + [?m :papod.membership/nick ?nick]]} + (d/db conn) channel-eid nick))) + build-tx + (fn [chan-eid chan-uuid* chan-ref] + (filterv some? + (cond-> [(when-not already? + {:db/ensure :papod.membership/attrs + :papod.membership/id (java.util.UUID/randomUUID) + :papod.membership/channel chan-ref + :papod.membership/nick nick + :papod.membership/joined-at (java.util.Date.)}) + {:db/ensure :papod.event/attrs + :db/id "new-event" + :papod.event/id event-id + :papod.event/channel chan-ref + :papod.event/type "user-join" + :papod.event/source-nick nick + :papod.event/created-at (java.util.Date.)} + [:papod.event/add-seq "new-event" chan-uuid*]] + (nil? chan-eid) + (conj {:db/ensure :papod.channel/attrs + :db/id "new-channel" + :papod.channel/id new-id + :papod.channel/network [:papod.network/id + (:network-id @client)] + :papod.channel/name handle + :papod.channel/type :papod.channel.type/public + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}))))] + (when conn + (try + @(d/transact conn + (build-tx channel-eid chan-uuid channel-ref)) + (catch Exception _ + ;; Likely a concurrent JOIN created the channel. + ;; Re-read and retry without the new-channel map. + (let [db2 (d/db conn) + eid2 (resolve-channel db2 handle) + uuid2 (when eid2 + (:papod.channel/id + (d/entity db2 eid2)))] + (when (and eid2 uuid2) + @(d/transact conn + (build-tx eid2 uuid2 eid2))))))) + ;; UPDATE in-memory. swap-vals! gives us an atomic + ;; snapshot of the channel membership before and after + ;; this nick was added, so concurrent JOINs from peers + ;; can't slip into our NAMES reply (or our broadcast + ;; recipient set, or our auto-op decision). + (let [[old-chs new-chs] + (if channels + (swap-vals! channels update handle + (fnil conj #{}) nick) + [nil nil]) + members-before (get old-chs handle #{}) + members-after (get new-chs handle #{})] + ;; Auto-op the first joiner only — empty members-before + ;; is the unambiguous signal, regardless of how many + ;; peers raced behind us. + (when (and (:ops components) + (empty? members-before)) + (swap! (:ops components) update handle + (fnil conj #{}) nick)) + ;; DELIVER to other channel members (not self) + (let [u (:user @client) + rname (or (:realname u) nick) + account (or (:account @client) "*") + base (str ":" nick " JOIN " handle) + ext (str base " " account " :" rname) + line (tag-line event-id base) + ext-line (tag-line event-id ext) + my-caps (or (:caps @client) #{}) + my-ej? (contains? my-caps + "extended-join") + echo (if my-ej? ext-line line)] + (when (and clients channels) + ;; Broadcast goes to who-was-here-before, NOT to + ;; concurrent joiners (they get their own JOIN + ;; broadcast from their own thread's perspective). + (doseq [member-nick members-before + :let [member (get @clients + member-nick) + ca (:client-atom member) + caps (or (when ca + (:caps @ca)) + #{}) + ej? (contains? + caps "extended-join")] + :when member] + (deliver-to-client! (:w member) + (if ej? ext-line line)))) + ;; metadata-2 sync: push subscribed key values + ;; from existing channel members and the channel + ;; itself to the joiner. + (when-let [meta-atom (:metadata components)] + (let [subs-atom (:metadata-subs components) + subs (when subs-atom + (get @subs-atom + (string/lower-case nick) #{}))] + (when (and (seq subs) clients) + (doseq [target (cons handle members-before) + :let [low (string/lower-case target) + kv (get @meta-atom low {})] + [k v] kv + :when (contains? subs k)] + (deliver-to-client! (:w @client) + (str ":" +server-name+ " METADATA " + target " " k " * :" v)))))) + ;; away-notify: notify channel that joiner + ;; is away + (when-let [a (:away @client)] + (let [src (client-prefix client) + aline (str ":" src " AWAY :" a)] + (when (and clients channels) + (doseq [member-nick members-before + :let [member (get @clients + member-nick) + ca (:client-atom member) + caps (or (when ca + (:caps @ca)) + #{})] + :when (and member + (contains? + caps + "away-notify"))] + (deliver-to-client! + (:w member) aline))))) + ;; Build replies for the joining client. Pin the + ;; member set to the snapshot taken at our swap-vals! + ;; so concurrent JOINs from peers can't show up in + ;; this client's NAMES burst. + (let [mp? (contains? my-caps + "multi-prefix") + uh? (contains? my-caps + "userhost-in-names") + members (names-for components handle + :multi-prefix? mp? + :userhost? uh? + :members members-after) + sym (chan-symbol components handle) + db' (when conn (d/db conn)) + ch-eid' (when db' + (resolve-channel db' handle)) + ent (when ch-eid' + (d/entity db' ch-eid')) + topic (:papod.channel/topic ent) + topic-by (:papod.channel/topic-set-by ent) + topic-at (:papod.channel/topic-set-at ent) + topic-lines + (when (seq topic) + [(str ":" +server-name+ " 332 " nick + " " handle " :" topic) + (str ":" +server-name+ " 333 " nick + " " handle " " + (or topic-by nick) " " + (or topic-at + (quot + (System/currentTimeMillis) + 1000)))]) + markread-line + (when (contains? my-caps + "draft/read-marker") + (let [stored (when (and conn ch-eid') + (ffirst + (d/q '{:find [?ts] + :in [$ ?ch ?nick] + :where + [[?r :papod.read-marker/channel ?ch] + [?r :papod.read-marker/nick ?nick] + [?r :papod.read-marker/timestamp ?ts]]} + db' ch-eid' nick)))] + (str ":" +server-name+ " MARKREAD " + handle " " + (if stored + (str "timestamp=" stored) + "*")))) + names-lines + (when members + [(str ":" +server-name+ " 353 " nick + " " sym " " handle " :" members) + (str ":" +server-name+ " 366 " nick + " " handle + " :End of /NAMES list")])] + (vec + (concat + [echo] + topic-lines + (when markread-line [markread-line]) + names-lines))))))))))))) + + +(defn- handle-join + [params client components] + (if (empty? params) + [(numeric-reply client "461" + "JOIN :Not enough parameters")] + (let [handles (string/split (first params) #",") + keys (when (> (count params) 1) + (string/split (second params) #","))] + (vec + (mapcat + (fn [i h] + (join-one! h (get keys i) client components)) + (range) handles))))) + +(defn- handle-rename + [params client components] + (let [{:keys [conn clients channels]} components + nick (client-target client) + old (some-> (first params) string/lower-case) + new (some-> (second params) string/lower-case) + reason (when (> (count params) 2) + (let [r (nth params 2)] + (cond-> r + (string/starts-with? r ":") (subs 1))))] + (cond + (or (< (count params) 2) + (string/blank? old) (string/blank? new)) + [(str "FAIL RENAME NEED_MORE_PARAMS" + " :Not enough parameters")] + + (not (channel-handle? old)) + [(numeric-reply client "403" + (str old " :No such channel"))] + + (not (channel-handle? new)) + [(str "FAIL RENAME INVALID_CHAN_NAME " + new " :Invalid channel name")] + + (not (and channels (seq (get @channels old)))) + [(numeric-reply client "403" + (str old " :No such channel"))] + + (and channels (seq (get @channels new))) + [(str "FAIL RENAME CHANNEL_NAME_IN_USE " + new " :Channel name already in use")] + + (not (and (:ops components) + (contains? (get @(:ops components) old) + nick))) + [(numeric-reply client "482" + (str old + " :You're not channel operator"))] + + :else + (let [src (client-prefix client) + members (get @channels old) + rline (str ":" src " RENAME " old " " new + (when reason (str " :" reason))) + part-l (str ":" src " PART " old + (when reason (str " :" reason))) + join-l (fn [member-nick] + (str ":" member-nick " JOIN " new))] + ;; Move channel state across atoms + (doseq [a-key [:channels :ops :voiced + :chan-modes :chan-keys :chan-limits + :chan-bans :chan-excepts :chan-invex + :invites]] + (when-let [a (get components a-key)] + (swap! a (fn [m] + (let [v (get m old)] + (cond-> (dissoc m old) + v (assoc new v))))))) + ;; Persist the rename + (when conn + (try + (let [db (d/db conn) + eid (resolve-channel db old)] + (when eid + @(d/transact conn + [[:db/add eid :papod.channel/name new]]))) + (catch Exception _))) + ;; Deliver to members based on cap support + (when (and clients channels) + (doseq [mn members + :let [m (get @clients mn) + ca (:client-atom m) + caps (or (when ca (:caps @ca)) #{})] + :when m] + (if (caps "draft/channel-rename") + (deliver-to-client! (:w m) rline) + (do (deliver-to-client! (:w m) part-l) + (deliver-to-client! (:w m) (join-l mn)))))) + [])))) + +(defn- handle-part + [params client components] + (let [{:keys [clients channels]} components + nick (client-target client) + handle (first params) + reason (when (> (count params) 1) + (string/join " " (rest params)))] + (cond + (empty? params) + [(numeric-reply client "461" "PART :Not enough parameters")] + + (not (contains? (get @channels handle) nick)) + [(numeric-reply client "442" + (str handle " :You're not on that channel"))] + + :else + (let [line (str ":" nick " PART " handle + (when reason (str " " reason)))] + ;; Notify channel members including self + (when (and clients channels) + (doseq [mn (get @channels handle) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line))) + ;; Remove from channel, ops, and voiced + (when channels + (swap! channels update handle disj nick)) + (when (:ops components) + (swap! (:ops components) update handle disj nick)) + (when (:voiced components) + (swap! (:voiced components) + update handle disj nick)) + [])))) + +(defn- format-history-event + [handle [_ type source-nick id pulled]] + (let [payload (:papod.event/payload pulled) + at (:papod.event/created-at pulled) + edit-ref (:papod.event/edit-of pulled) + reply-ref (:papod.event/reply-to pulled) + target (or (:papod.event/target-nick pulled) + handle) + extra-tags (when (= type "user-tagmsg") + (when (seq payload) + (string/split payload #";"))) + tags (cond-> [] + edit-ref + (conj (str "+papod/edit=" + (:papod.event/id edit-ref))) + reply-ref + (conj (str "+papod/reply=" + (:papod.event/id + reply-ref))) + (seq extra-tags) + (into extra-tags)) + raw (case type + "user-message" + (str ":" source-nick " PRIVMSG " + target " " payload) + "user-join" + (str ":" source-nick " JOIN " handle) + "user-kick" + (str ":ChanServ KICK " handle " " + payload) + "user-edit" + (str ":" source-nick " PRIVMSG " + target " " payload) + "user-delete" + (str ":" source-nick " PRIVMSG " + target " [deleted]") + "user-topic" + (str ":" source-nick " TOPIC " handle + " :" payload) + "user-tagmsg" + (str ":" source-nick " TAGMSG " target) + (str ":" source-nick " NOTICE " + handle " :" type))] + (if at + (tag-line-at id at tags raw) + (tag-line-with id tags raw)))) + +(defn- parse-chathistory-anchor + [s] + (cond + (= s "*") + {:kind :star} + (string/starts-with? (or s "") "msgid=") + (let [id-str (subs s 6) + id (try (java.util.UUID/fromString id-str) + (catch Exception _ nil))] + (when id {:kind :msgid :id id})) + (string/starts-with? (or s "") "timestamp=") + {:kind :timestamp :ts (subs s 10)} + :else nil)) + +(defn- event-time-iso + [evt] + (when-let [d (:papod.event/created-at (last evt))] + (iso-time d))) + +(defn- event-msgid + [evt] + (nth evt 3)) + +(defn- match-anchor + [evt anchor] + (case (:kind anchor) + :msgid (= (event-msgid evt) (:id anchor)) + :timestamp (= (event-time-iso evt) (:ts anchor)) + false)) + +(defn- before-anchor? + [evt anchor] + (case (:kind anchor) + :timestamp (neg? (compare (event-time-iso evt) + (:ts anchor))) + :msgid false + false)) + +(defn- after-anchor? + [evt anchor] + (case (:kind anchor) + :timestamp (pos? (compare (event-time-iso evt) + (:ts anchor))) + :msgid false + false)) + +(defn- select-events + [all subcmd a1 a2 limit] + (let [n (count all)] + (case subcmd + "LATEST" + (let [from (cond + (= (:kind a1) :star) 0 + (= (:kind a1) :msgid) + (or (some (fn [[i e]] + (when (match-anchor e a1) (inc i))) + (map-indexed vector all)) + 0) + (= (:kind a1) :timestamp) + (or (some (fn [[i e]] + (when (after-anchor? e a1) i)) + (map-indexed vector all)) + n) + :else 0) + tail (subvec (vec all) from)] + (vec (take-last limit tail))) + + "BEFORE" + (let [from (cond + (= (:kind a1) :msgid) + (some (fn [[i e]] + (when (match-anchor e a1) i)) + (map-indexed vector all)) + (= (:kind a1) :timestamp) + (or (some (fn [[i e]] + (when-not (before-anchor? e a1) i)) + (map-indexed vector all)) + n) + :else nil)] + (if from + (vec (take-last limit (subvec (vec all) 0 from))) + [])) + + "AFTER" + (let [from (cond + (= (:kind a1) :msgid) + (some (fn [[i e]] + (when (match-anchor e a1) (inc i))) + (map-indexed vector all)) + (= (:kind a1) :timestamp) + (or (some (fn [[i e]] + (when (after-anchor? e a1) i)) + (map-indexed vector all)) + n) + :else nil)] + (if from + (vec (take limit (subvec (vec all) from))) + [])) + + "BETWEEN" + (let [idx (fn [a] + (cond + (= (:kind a) :msgid) + (some (fn [[i e]] + (when (match-anchor e a) i)) + (map-indexed vector all)) + (= (:kind a) :timestamp) + (some (fn [[i e]] + (when-not (before-anchor? e a) i)) + (map-indexed vector all)) + :else nil)) + i1 (idx a1) + i2 (idx a2) + lo (when (and i1 i2) (min i1 i2)) + hi (when (and i1 i2) (max i1 i2)) + forward? (and i1 i2 (<= i1 i2))] + (if (and lo hi (< lo hi)) + (let [slice (subvec (vec all) (inc lo) hi)] + (if forward? + (vec (take limit slice)) + (vec (take-last limit slice)))) + [])) + + "AROUND" + (let [idx (cond + (= (:kind a1) :msgid) + (some (fn [[i e]] + (when (match-anchor e a1) i)) + (map-indexed vector all)) + (= (:kind a1) :timestamp) + (some (fn [[i e]] + (when-not (before-anchor? e a1) i)) + (map-indexed vector all)) + :else nil)] + (if idx + (let [half (quot limit 2) + lo (max 0 (- idx half)) + hi (min n (+ lo limit))] + (vec (subvec (vec all) lo hi))) + [])) + + []))) + +(defn- chathistory-targets + [db nick channels] + (let [chan-rows + (when channels + (for [[handle members] @channels + :when (contains? members nick) + :let [ch-eid (resolve-channel db handle)] + :when ch-eid + :let [ts (->> + (d/q '{:find [(max ?t)] + :in [$ ?ch] + :where + [[?e :papod.event/channel + ?ch] + [?e :papod.event/type + "user-message"] + [?e :papod.event/created-at + ?t]]} + db ch-eid) + ffirst)] + :when ts] + [handle ts])) + dm-pairs + (d/q '{:find [?source ?target ?t] + :in [$ ?nick] + :where + [[?e :papod.event/source-nick ?source] + [?e :papod.event/target-nick ?target] + [?e :papod.event/type "user-message"] + [?e :papod.event/created-at ?t] + (or-join [?nick ?source ?target] + [(= ?source ?nick)] + [(= ?target ?nick)])]} + db nick) + dm-rows + (->> dm-pairs + (map (fn [[s t ts]] + [(if (= s nick) t s) ts])) + (group-by first) + (map (fn [[peer entries]] + [peer (apply max-key + #(.getTime ^java.util.Date %) + (map second entries))])))] + (concat chan-rows dm-rows))) + +(defn- handle-chathistory-targets + [params client components] + (let [{:keys [conn channels]} components + nick (client-target client) + a1 (parse-chathistory-anchor (second params)) + a2 (parse-chathistory-anchor + (when (>= (count params) 3) (nth params 2))) + limit (try + (Long/parseLong (last params)) + (catch Exception _ 50)) + db (when conn (d/db conn))] + (cond + (< (count params) 4) + [(str "FAIL CHATHISTORY NEED_MORE_PARAMS TARGETS " + ":Not enough parameters")] + + (or (nil? a1) (nil? a2) + (not= (:kind a1) :timestamp) + (not= (:kind a2) :timestamp)) + [(str "FAIL CHATHISTORY INVALID_PARAMS TARGETS " + ":Invalid anchor")] + + :else + (let [start (:ts a1) + end (:ts a2) + rows (chathistory-targets db nick channels) + in-window (filter + (fn [[_ ts]] + (let [iso (iso-time ts)] + (and (pos? (compare iso start)) + (neg? (compare iso end))))) + rows) + sorted (sort-by (fn [[_ ts]] (.getTime ^java.util.Date ts)) + in-window) + chosen (take limit sorted) + batch-id (str (java.util.UUID/randomUUID))] + (into + [(str "BATCH +" batch-id + " draft/chathistory-targets")] + (concat + (map (fn [[handle ts]] + (str "@batch=" batch-id + " :" +server-name+ + " CHATHISTORY TARGETS " handle " " + (iso-time ts))) + chosen) + [(str "BATCH -" batch-id)])))))) + +(defn- handle-chathistory + [params client components] + (let [{:keys [conn channels]} components + nick (client-target client) + subcmd (when (seq params) + (string/upper-case (first params))) + target (second params) + db (when conn (d/db conn)) + chan? (and target (channel-handle? target)) + chan-eid (when (and db target chan?) + (resolve-channel db target)) + joined? (and chan? channels + (contains? (get @channels target) + nick)) + limit (try + (Long/parseLong (last params)) + (catch Exception _ 50)) + between? (= subcmd "BETWEEN") + a1 (when (>= (count params) 3) + (parse-chathistory-anchor (nth params 2))) + a2 (when (and between? + (>= (count params) 4)) + (parse-chathistory-anchor (nth params 3)))] + (cond + (= subcmd "TARGETS") + (handle-chathistory-targets params client components) + + (< (count params) 3) + [(str "FAIL CHATHISTORY NEED_MORE_PARAMS " + (or subcmd "*") + " :Not enough parameters")] + + (or (and chan? (nil? chan-eid)) + (and chan? (not joined?))) + [(str "FAIL CHATHISTORY INVALID_TARGET " + subcmd " " + (or target "*") " :No such channel")] + + (nil? a1) + [(str "FAIL CHATHISTORY INVALID_PARAMS " + subcmd " " + (or target "*") " :Invalid anchor")] + + (and between? (nil? a2)) + [(str "FAIL CHATHISTORY INVALID_PARAMS " + subcmd " " + (or target "*") " :Invalid anchor")] + + :else + (let [event-playback? + (contains? (or (:caps @client) #{}) + "draft/event-playback") + all (if chan? + (fetch-history-all db chan-eid) + (fetch-dm-history-all + db nick (:account @client) target)) + all (if event-playback? + all + (filter + (fn [[_ type & _]] + (#{"user-message" + "user-edit" + "user-delete"} type)) + all)) + events (select-events + all subcmd a1 a2 limit) + batch-id (str (java.util.UUID/randomUUID))] + (into + [(str "BATCH +" batch-id " chathistory " + target)] + (concat + (map (fn [evt] + (str "@batch=" batch-id ";" + (subs (format-history-event + target evt) + 1))) + events) + [(str "BATCH -" batch-id)])))))) + +(defn- handle-redact + [params client components] + (let [{:keys [conn clients channels]} components + nick (client-target client)] + (cond + (< (count params) 2) + [(numeric-reply client "461" + "REDACT :Not enough parameters")] + + :else + (let [[handle target-id-str & reason-parts] params + reason (when (seq reason-parts) + (cond-> (string/join " " reason-parts) + (string/starts-with? + (first reason-parts) ":") + (subs 1))) + target-id (try (java.util.UUID/fromString target-id-str) + (catch Exception _ nil)) + db (when conn (d/db conn)) + chan? (channel-handle? handle) + chan-eid (when (and db chan?) + (resolve-channel db handle)) + target-eid (when (and db target-id) + (ffirst + (d/q '{:find [?e] + :in [$ ?id] + :where [[?e :papod.event/id ?id]]} + db target-id)))] + (cond + (and chan? (nil? chan-eid)) + [(str "FAIL REDACT INVALID_TARGET " handle + " :No such channel")] + (nil? target-eid) + [(str "FAIL REDACT UNKNOWN_MSGID " handle " " + target-id-str " :Message not found")] + ;; Channel: ownership or op; DM: must be author + (let [event (d/entity db target-eid)] + (if chan? + (and (not= nick + (:papod.event/source-nick event)) + (not (is-op? components handle nick)) + (not (has-access? db chan-eid nick + #{"owner" "op"}))) + (not= nick + (:papod.event/source-nick event)))) + [(str "FAIL REDACT REDACT_FORBIDDEN " handle " " target-id-str + " :Not authorised to delete this message")] + :else + (let [msg-id (java.util.UUID/randomUUID) + chan-uuid (when chan-eid + (:papod.channel/id + (d/entity db chan-eid)))] + @(d/transact conn + (cond-> [{:db/ensure :papod.event/attrs + :db/id "new-event" + :papod.event/id msg-id + :papod.event/type "user-delete" + :papod.event/source-nick nick + :papod.event/delete-of target-eid + :papod.event/created-at (java.util.Date.)}] + chan? + (-> (update 0 assoc + :papod.event/channel chan-eid) + (conj [:papod.event/add-seq + "new-event" chan-uuid])) + (not chan?) + (-> (update 0 assoc + :papod.event/target-nick handle)))) + ;; Deliver REDACT + (let [src (client-prefix client) + line (str ":" src " REDACT " handle + " " target-id-str + (when reason + (str " :" reason)))] + (cond + chan? + (when (and clients channels) + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn) + ca (when m (:client-atom m)) + caps (or (some-> ca deref + :caps) #{})] + :when (and m (caps "draft/message-redaction"))] + (deliver-to-client! (:w m) line))) + clients + (when-let [m (get @clients handle)] + (let [ca (:client-atom m) + caps (or (some-> ca deref + :caps) #{})] + (when (caps "draft/message-redaction") + (deliver-to-client! (:w m) line))))) + [line]))))))) + +(defn- handle-edit + [params client components] + (let [{:keys [conn clients channels]} components + nick (client-target client)] + (cond + (< (count params) 3) + [(str "FAIL EDIT INVALID_TARGET " (first params) + " :Not enough parameters")] + + :else + (let [[handle target-id-str & text-parts] params + new-text (string/join " " text-parts) + target-id (try (java.util.UUID/fromString target-id-str) + (catch Exception _ nil)) + db (when conn (d/db conn)) + chan-eid (when db (resolve-channel db handle)) + target-eid (when (and db target-id) + (find-event-eid db target-id)) + event (when target-eid (d/entity db target-eid))] + (cond + (nil? chan-eid) + [(str "FAIL EDIT INVALID_TARGET " handle + " :You cannot edit messages in " handle)] + (nil? target-eid) + [(str "FAIL EDIT UNKNOWN_MSGID " handle " " target-id-str + " :This message does not exist or is too old")] + (not= nick (:papod.event/source-nick event)) + [(str "FAIL EDIT EDIT_FORBIDDEN " handle " " target-id-str + " :You are not authorised to edit this message")] + :else + (let [msg-id (java.util.UUID/randomUUID) + chan-uuid (:papod.channel/id (d/entity db chan-eid))] + @(d/transact conn + [{:db/ensure :papod.event/attrs + :db/id "new-event" + :papod.event/id msg-id + :papod.event/channel chan-eid + :papod.event/type "user-edit" + :papod.event/source-nick nick + :papod.event/payload new-text + :papod.event/edit-of target-eid + :papod.event/created-at (java.util.Date.)} + [:papod.event/add-seq "new-event" chan-uuid]]) + ;; Deliver EDIT to channel members + (let [line (tag-line msg-id + (str ":" nick " EDIT " handle " " target-id-str + " " new-text))] + (when (and clients channels) + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line)))) + [])))))) + +(defn- handle-tagmsg + [message client components] + (let [{:keys [conn clients channels]} components + nick (client-target client) + tags (:tags message) + params (clean-params (:params message)) + handle (first params) + reply-id-str (get tags "+reply") + react-emoji (get tags "+draft/react") + unreact-emoji (get tags "+draft/unreact") + chan? (channel-handle? handle)] + (cond + (empty? params) + [(numeric-reply client "461" "TAGMSG :Not enough parameters")] + + ;; Reaction: +reply + +draft/react + (and reply-id-str react-emoji) + (let [target-id (try (java.util.UUID/fromString reply-id-str) + (catch Exception _ nil)) + db (when conn (d/db conn)) + chan-eid (when db (resolve-channel db handle)) + target-eid (when (and db target-id) + (find-event-eid db target-id))] + (cond + (nil? chan-eid) + [(numeric-reply client "403" + (str handle " :No such channel"))] + (nil? target-eid) + [(numeric-reply client "404" ":Message not found")] + :else + (do @(d/transact conn + [{:db/ensure :papod.reaction/attrs + :papod.reaction/id (java.util.UUID/randomUUID) + :papod.reaction/event target-eid + :papod.reaction/nick nick + :papod.reaction/emoji react-emoji}]) + (let [line (tag-line-with (java.util.UUID/randomUUID) + [(str "+reply=" reply-id-str) + (str "+draft/react=" react-emoji)] + (str ":" nick " TAGMSG " handle))] + (when (and clients channels) + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line)))) + []))) + + ;; Unreaction: +reply + +draft/unreact + (and reply-id-str unreact-emoji) + (let [target-id (try (java.util.UUID/fromString reply-id-str) + (catch Exception _ nil)) + db (when conn (d/db conn)) + target-eid (when (and db target-id) + (find-event-eid db target-id)) + react-eid (when target-eid + (ffirst + (d/q '{:find [?r] + :in [$ ?evt ?nick ?emoji] + :where [[?r :papod.reaction/event ?evt] + [?r :papod.reaction/nick ?nick] + [?r :papod.reaction/emoji ?emoji]]} + db target-eid nick unreact-emoji)))] + (when react-eid + @(d/transact conn [[:db/retractEntity react-eid]])) + (let [line (tag-line-with (java.util.UUID/randomUUID) + [(str "+reply=" reply-id-str) + (str "+draft/unreact=" unreact-emoji)] + (str ":" nick " TAGMSG " handle))] + (when (and clients channels) + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line)))) + []) + + ;; Typing notification: +typing tag + (get tags "+typing") + (let [line (tag-line-with (java.util.UUID/randomUUID) + [(str "+typing=" (get tags "+typing"))] + (str ":" nick " TAGMSG " handle))] + (when (and clients channels) + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line))) + []) + + ;; Generic TAGMSG: forward client tags + :else + (let [user-tag-pairs (->> tags + (filter (fn [[k _]] + (string/starts-with? k "+"))) + (sort-by first)) + user-tags (map (fn [[k v]] (str k "=" v)) + user-tag-pairs) + persist? (contains? tags "+draft/persist") + user-tags-line (string/join ";" user-tags) + base-line (str ":" nick " TAGMSG " handle) + msg-id (java.util.UUID/randomUUID) + msg-at (java.util.Date.) + tagged (tag-line-at msg-id msg-at user-tags base-line) + my-caps (or (:caps @client) #{}) + tags-cap? (contains? my-caps "message-tags") + recipient-exists? + (cond + chan? (and channels + (seq (get @channels handle))) + :else (and clients + (get @clients handle)))] + (cond + (and (not chan?) (not recipient-exists?)) + [(numeric-reply client "401" + (str handle " :No such nick/channel"))] + + (and chan? (not recipient-exists?)) + [(numeric-reply client "403" + (str handle " :No such channel"))] + + :else + (do + ;; Persist if +draft/persist was given + (when (and persist? conn) + (let [db (when conn (d/db conn)) + channel-eid (when chan? (resolve-channel db handle)) + chan-uuid (when channel-eid + (:papod.channel/id + (d/entity db channel-eid))) + src-acct (when-not chan? + (:account @client)) + tgt-acct (when-not chan? + (nick-account components handle))] + @(d/transact conn + (cond-> [{:db/ensure :papod.event/attrs + :db/id "new-event" + :papod.event/id msg-id + :papod.event/type "user-tagmsg" + :papod.event/source-nick nick + :papod.event/payload user-tags-line + :papod.event/created-at msg-at}] + chan? + (-> (update 0 assoc :papod.event/channel channel-eid) + (conj [:papod.event/add-seq "new-event" chan-uuid])) + (not chan?) + (-> (update 0 assoc + :papod.event/target-nick handle)) + src-acct + (-> (update 0 assoc + :papod.event/source-account + src-acct)) + tgt-acct + (-> (update 0 assoc + :papod.event/target-account + tgt-acct)))))) + (if chan? + (doseq [mn (get @channels handle) + :when (not= mn nick) + :let [m (get @clients mn) + ca (when m (:client-atom m)) + caps (when ca (or (:caps @ca) #{}))] + :when (and m (caps "message-tags"))] + (deliver-to-client! (:w m) tagged)) + (when-let [m (get @clients handle)] + (let [ca (:client-atom m) + caps (when ca (or (:caps @ca) #{}))] + (when (caps "message-tags") + (deliver-to-client! (:w m) tagged))))) + (if (and (contains? my-caps "echo-message") + tags-cap?) + [tagged] + []))))))) + +(defn- notify-metadata-subs! + "Deliver a `:source METADATA target key * [:value]` line to every + subscriber that shares a channel with the target." + [components client target-display key value] + (let [{:keys [clients channels metadata-subs]} components + nick (client-target client) + target (string/lower-case target-display) + chan? (channel-handle? target-display) + ;; nicks who could receive: members of relevant channels + relevant-chans + (when channels + (if chan? + (when (contains? @channels target) + [target]) + (filter (fn [[ch members]] + (contains? members target)) + @channels))) + candidate-nicks + (set + (mapcat + (fn [ch] + (let [members (if (string? ch) + (get @channels ch) + (val ch))] + members)) + (or relevant-chans []))) + line (if value + (str ":" nick " METADATA " + target-display " " key " * :" value) + (str ":" nick " METADATA " + target-display " " key " *"))] + (when (and clients metadata-subs) + (doseq [mn candidate-nicks + :when (not= mn nick) + :let [m (get @clients mn) + subs (get @metadata-subs + (string/lower-case mn) #{})] + :when (and m (contains? subs key))] + (deliver-to-client! (:w m) line))))) + +(defn- metadata-target-key + "Validate a METADATA target and return its canonical lower-case key + (or nil for invalid). `*` always resolves to the caller's nick." + [components client target] + (let [nick (client-target client) + lo (when target (string/lower-case target))] + (cond + (or (nil? target) (= "*" target)) + (string/lower-case nick) + + (channel-handle? target) + (when (and (:channels components) + (seq (get @(:channels components) lo))) + lo) + + :else + (when-let [clients (:clients components)] + (let [matches (filter (fn [[k _]] + (= (string/lower-case k) lo)) + @clients)] + (when (seq matches) + lo)))))) + +(defn- metadata-target-display + "Echo the user-supplied target back, resolving `*` to the caller's + own nick (some clients require the canonical name)." + [client requested] + (if (or (nil? requested) (= "*" requested)) + (client-target client) + requested)) + +(defn- pending-metadata-handler + "Handles METADATA SET/GET/LIST/CLEAR before registration completes + (draft/metadata-2 before-connect). Values are buffered in the + client atom until the welcome burst replays them." + [params client] + (let [target (first params) + subcmd (when (> (count params) 1) + (string/upper-case (second params))) + rest-args (drop 2 params)] + (cond + (or (nil? target) (string/blank? target)) + [(numeric-reply client "461" + "METADATA :Not enough parameters")] + + (nil? subcmd) + [(numeric-reply client "461" + "METADATA :Not enough parameters")] + + ;; Only "*" target makes sense pre-registration. + (not= target "*") + [(str "FAIL METADATA INVALID_TARGET " + target " :Invalid target")] + + :else + (case subcmd + "GET" + (let [keys-asked (vec rest-args) + cur (or (:pending-metadata @client) {})] + (mapv + (fn [k] + (if-let [v (get cur k)] + (numeric-reply client "761" + (str "* " k " * :" v)) + (numeric-reply client "766" + (str "* " k + " :no matching key")))) + keys-asked)) + + "LIST" + (let [cur (or (:pending-metadata @client) {})] + (mapv + (fn [[k v]] + (numeric-reply client "761" + (str "* " k " * :" v))) + (sort-by first cur))) + + "SET" + (let [k (first rest-args) + v-parts (rest rest-args) + raw-v (when (seq v-parts) + (string/join " " v-parts)) + v (when raw-v + (cond-> raw-v + (string/starts-with? raw-v ":") (subs 1))) + v-bytes (when v + (alength (.getBytes ^String v "UTF-8")))] + (cond + (nil? k) + [(numeric-reply client "461" + "METADATA :Not enough parameters")] + + (and v-bytes (> v-bytes 360)) + [(str "FAIL METADATA VALUE_INVALID :Value of " + k " is too long")] + + (or (nil? v) (string/blank? v)) + (do (swap! client update :pending-metadata + (fnil dissoc {}) k) + [(numeric-reply client "766" + (str "* " k " :no matching key"))]) + + :else + (do (swap! client update :pending-metadata + (fnil assoc {}) k v) + [(numeric-reply client "761" + (str "* " k " * :" v))]))) + + "CLEAR" + (do (swap! client assoc :pending-metadata {}) + [(numeric-reply client "762" + "* :end of metadata")]) + + ;; SUB/UNSUB/SUBS pre-reg: not useful. + [(numeric-reply client "461" + "METADATA :Not enough parameters")])))) + +(defn- handle-metadata + [params client components] + (if-not (:registered? @client) + (pending-metadata-handler params client) + (let [nick (client-target client) + target (first params) + subcmd (when (> (count params) 1) + (string/upper-case (second params))) + rest-args (drop 2 params) + meta-atom (:metadata components) + subs-atom (:metadata-subs components) + tkey (metadata-target-key components client target) + echo-target (metadata-target-display client target) + batch-id (str (java.util.UUID/randomUUID)) + wrap-batch + (fn [body-lines] + (into [(str "BATCH +" batch-id " metadata" + (when echo-target + (str " " echo-target)))] + (concat + (map (fn [l] + (if (string/starts-with? l "@") + (let [sp (.indexOf l " ")] + (str "@batch=" batch-id ";" + (subs l 1 sp) + (subs l sp))) + (str "@batch=" batch-id " " l))) + body-lines) + [(str "BATCH -" batch-id)])))] + (cond + (or (nil? target) (string/blank? target)) + [(numeric-reply client "461" + "METADATA :Not enough parameters")] + + (nil? subcmd) + [(numeric-reply client "461" + "METADATA :Not enough parameters")] + + (and (not= subcmd "SUB") + (not= subcmd "UNSUB") + (not= subcmd "SUBS") + (nil? tkey)) + [(str "FAIL METADATA INVALID_TARGET " + target " :Invalid target")] + + :else + (case subcmd + "GET" + (let [keys-asked (vec rest-args) + cur (get @meta-atom tkey {}) + body + (mapv + (fn [k] + (if-let [v (get cur k)] + (numeric-reply client "761" + (str echo-target " " k " * :" v)) + (numeric-reply client "766" + (str echo-target " " k + " :no matching key")))) + keys-asked)] + (wrap-batch body)) + + "LIST" + (let [cur (get @meta-atom tkey {}) + body + (mapv + (fn [[k v]] + (numeric-reply client "761" + (str echo-target " " k " * :" v))) + (sort-by first cur))] + (wrap-batch body)) + + "SET" + (let [k (first rest-args) + v-parts (rest rest-args) + raw-v (when (seq v-parts) + (string/join " " v-parts)) + v (when raw-v + (cond-> raw-v + (string/starts-with? raw-v ":") (subs 1))) + v-bytes (when v + (alength (.getBytes ^String v "UTF-8")))] + (cond + (nil? k) + [(numeric-reply client "461" + "METADATA :Not enough parameters")] + + ;; Only allow setting on self (or chanop on channel) + (not (or (= tkey (string/lower-case nick)) + (and (channel-handle? target) + (:ops components) + (contains? + (get @(:ops components) tkey) + nick)))) + [(str "FAIL METADATA KEY_NO_PERMISSION " + echo-target " " k + " :You don't have permission to set this key")] + + (and v-bytes (> v-bytes 360)) + [(str "FAIL METADATA VALUE_INVALID :Value of " + k " is too long")] + + (or (nil? v) (string/blank? v)) + (do (when meta-atom + (swap! meta-atom update tkey dissoc k)) + (notify-metadata-subs! components client + echo-target k nil) + [(numeric-reply client "766" + (str echo-target " " k + " :no matching key"))]) + + :else + (do (when meta-atom + (swap! meta-atom assoc-in [tkey k] v)) + (notify-metadata-subs! components client + echo-target k v) + [(numeric-reply client "761" + (str echo-target " " k " * :" v))]))) + + "CLEAR" + (do (when meta-atom (swap! meta-atom dissoc tkey)) + [(numeric-reply client "762" + (str echo-target + " :end of metadata"))]) + + "SUB" + (let [keys-asked (vec rest-args) + k-low (string/lower-case nick)] + (when subs-atom + (swap! subs-atom update k-low + (fnil into #{}) keys-asked)) + (when (seq keys-asked) + [(numeric-reply client "770" + (string/join " " keys-asked))])) + + "UNSUB" + (let [keys-asked (vec rest-args) + k-low (string/lower-case nick)] + (when subs-atom + (swap! subs-atom update k-low + (fnil (fn [s ks] + (apply disj s ks)) #{}) keys-asked)) + (when (seq keys-asked) + [(numeric-reply client "771" + (string/join " " keys-asked))])) + + "SUBS" + (let [k-low (string/lower-case nick) + cur (when subs-atom + (get @subs-atom k-low #{}))] + [(numeric-reply client "772" + (if (seq cur) + (string/join " " cur) + ":none"))]) + + [(str "FAIL METADATA INVALID_SUBCOMMAND " + subcmd " :Unknown subcommand")]))))) + +(defn- handle-markread + [params client components] + (let [{:keys [conn]} components + nick (client-target client)] + (cond + (empty? params) + [(str "FAIL MARKREAD NEED_MORE_PARAMS" + " :Missing parameters")] + + :else + (let [handle (first params) + timestamp (second params) + db (when conn (d/db conn)) + chan? (channel-handle? handle) + chan-eid (when (and db chan?) (resolve-channel db handle)) + ts-rgx #"timestamp=(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)" + user-key (when-not chan? + [(string/lower-case nick) + (string/lower-case handle)]) + stored-fn + (fn [] + (cond + (and chan? db chan-eid) + (ffirst + (d/q '{:find [?ts] + :in [$ ?ch ?nick] + :where + [[?r :papod.read-marker/channel ?ch] + [?r :papod.read-marker/nick ?nick] + [?r :papod.read-marker/timestamp ?ts]]} + db chan-eid nick)) + user-key + (some-> components :user-markers + deref (get user-key))))] + (cond + ;; SET with valid timestamp=... + (and timestamp + (re-matches ts-rgx timestamp)) + (let [ts-only (second + (re-matches ts-rgx timestamp)) + stored (stored-fn) + advance? (or (nil? stored) + (pos? (compare ts-only stored))) + effective (if advance? ts-only stored) + line (str ":" +server-name+ " MARKREAD " + handle " timestamp=" effective)] + (when advance? + (cond + (and chan? conn chan-eid) + @(d/transact conn + [{:papod.read-marker/channel chan-eid + :papod.read-marker/nick nick + :papod.read-marker/timestamp + ts-only}]) + user-key + (when (:user-markers components) + (swap! (:user-markers components) + assoc user-key ts-only)))) + ;; Broadcast to other sessions of the same + ;; account that negotiated draft/read-marker. + (when advance? + (when-let [acct (:account @client)] + (when-let [ac (:account-clients + components)] + (doseq [c (get @ac acct #{}) + :when (and (not= c client) + (contains? + (or (:caps @c) #{}) + "draft/read-marker"))] + (deliver-to-client! (:w @c) line))))) + [line]) + + ;; SET with bad timestamp shape: reject + (and timestamp + (not (re-matches ts-rgx timestamp))) + [(str "FAIL MARKREAD INVALID_PARAMS " handle + " " timestamp + " :Invalid timestamp")] + + ;; GET + :else + (let [stored (stored-fn)] + [(str ":" +server-name+ " MARKREAD " handle " " + (if stored + (str "timestamp=" stored) + "*"))])))))) + +(defn- handle-topic + [params client components] + (let [{:keys [conn clients channels]} components + nick (client-target client) + handle (first params)] + (cond + (empty? params) + [(numeric-reply client "461" + "TOPIC :Not enough parameters")] + + ;; Channel doesn't exist at all + (and channels (empty? (get @channels handle))) + [(numeric-reply client "403" + (str handle " :No such channel"))] + + (not (contains? (get @channels handle) nick)) + [(numeric-reply client "442" + (str handle " :You're not on that channel"))] + + ;; Query topic: TOPIC #channel + (= 1 (count params)) + (let [db (when conn (d/db conn)) + ch-eid (when db (resolve-channel db handle)) + topic (when ch-eid + (:papod.channel/topic + (d/entity db ch-eid)))] + (if (and topic (not (string/blank? topic))) + [(numeric-reply client "332" + (str handle " :" topic))] + [(numeric-reply client "331" + (str handle " :No topic is set"))])) + + ;; +t mode: only ops can set topic + (and (> (count params) 1) + (let [modes (when (:chan-modes components) + (get @(:chan-modes components) + handle "+nt"))] + (and (string/includes? (or modes "") "t") + (not (is-op? components handle nick))))) + [(numeric-reply client "482" + (str handle + " :You're not channel operator"))] + + ;; Set topic: TOPIC #channel :new topic + :else + (let [new-topic (string/join " " (rest params)) + new-topic (cond-> new-topic + (string/starts-with? new-topic ":") + (subs 1)) + db (when conn (d/db conn)) + ch-eid (when db (resolve-channel db handle)) + chan-uuid (when ch-eid + (:papod.channel/id + (d/entity db ch-eid))) + msg-id (java.util.UUID/randomUUID) + msg-at (java.util.Date.)] + (when ch-eid + @(d/transact conn + [[:db/add ch-eid :papod.channel/topic + new-topic] + [:db/add ch-eid :papod.channel/topic-set-by + nick] + [:db/add ch-eid :papod.channel/topic-set-at + (quot (.getTime msg-at) 1000)] + {:db/ensure :papod.event/attrs + :db/id "topic-event" + :papod.event/id msg-id + :papod.event/channel ch-eid + :papod.event/type "user-topic" + :papod.event/source-nick nick + :papod.event/payload new-topic + :papod.event/created-at msg-at} + [:papod.event/add-seq "topic-event" chan-uuid]])) + ;; Notify all channel members + (let [raw (str ":" nick " TOPIC " handle " :" + new-topic) + line (tag-line-at msg-id msg-at [] raw)] + (when (and clients channels) + (doseq [mn (get @channels handle) + :let [m (get @clients mn) + ca (when m (:client-atom m)) + caps (or (some-> ca deref :caps) #{})] + :when m] + (if (or (caps "message-tags") + (caps "server-time")) + (deliver-to-client! (:w m) line) + (deliver-to-client! (:w m) raw))))) + [])))) + +(defn- handle-kick + [params client components] + (let [{:keys [clients channels]} components + nick (client-target client) + handle (first params) + target (second params) + reason (if (> (count params) 2) + (let [r (string/join " " (drop 2 params))] + (cond-> r + (string/starts-with? r ":") (subs 1))) + nick)] + (cond + (< (count params) 2) + [(numeric-reply client "461" + "KICK :Not enough parameters")] + + ;; Channel doesn't exist + (empty? (get @channels handle)) + [(numeric-reply client "403" + (str handle " :No such channel"))] + + (not (contains? (get @channels handle) nick)) + [(numeric-reply client "442" + (str handle " :You're not on that channel"))] + + (not (contains? (get @channels handle) target)) + [(numeric-reply client "441" + (str target " " handle + " :They aren't on that channel"))] + + ;; Check op privileges + (not (is-op? components handle nick)) + [(numeric-reply client "482" + (str handle + " :You're not channel operator"))] + + :else + (do + ;; Remove from channel, ops, and voiced + (when channels + (swap! channels update handle disj target)) + (when (:ops components) + (swap! (:ops components) update handle + disj target)) + (when (:voiced components) + (swap! (:voiced components) update handle + disj target)) + ;; Notify channel + kicked user + (let [line (str ":" nick " KICK " handle " " + target " :" reason)] + (when clients + (doseq [mn (conj (get @channels handle) target) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line)))) + [])))) + +(defn- handle-notice + [params client components] + (let [{:keys [clients channels]} components + nick (client-target client) + target (first params) + caps (or (:caps @client) #{}) + echo? (contains? caps "echo-message") + msg-id (str (java.util.UUID/randomUUID)) + deliver! + (fn [raw recipient] + (let [ca (:client-atom recipient) + rcaps (or (when ca (:caps @ca)) #{}) + tagged (tag-line msg-id raw) + line (if (or (rcaps "message-tags") + (rcaps "server-time")) + tagged + raw)] + (deliver-to-client! (:w recipient) line)))] + (cond + ;; Per RFC: NOTICE must never generate error replies + (or (empty? params) (< (count params) 2)) + [] + + (channel-handle? target) + (let [content (string/join " " (rest params)) + raw (str ":" nick " NOTICE " target " " + content)] + (when (and clients channels + (get @channels target)) + (doseq [mn (get @channels target) + :when (not= mn nick) + :let [m (get @clients mn)] + :when m] + (deliver! raw m))) + (if echo? + [(if (or (caps "message-tags") + (caps "server-time")) + (tag-line msg-id raw) + raw)] + [])) + + :else + (let [content (string/join " " (rest params)) + raw (str ":" nick " NOTICE " target " " + content)] + (when-let [m (and clients (get @clients target))] + (deliver! raw m)) + (if echo? + [(if (or (caps "message-tags") + (caps "server-time")) + (tag-line msg-id raw) + raw)] + []))))) + +(defn- handle-away + [params client components] + (let [msg (when (seq params) + (let [raw (string/join " " params)] + (cond-> raw + (string/starts-with? raw ":") (subs 1)))) + nick (client-target client) + src (client-prefix client) + notify! + (fn [line] + (let [{:keys [clients channels]} components + seen (atom #{nick})] + (when (and clients channels) + (doseq [[ch members] @channels + :when (contains? members nick) + mn members + :when (not (contains? @seen mn)) + :let [m (get @clients mn) + ca (:client-atom m) + caps (or (when ca + (:caps @ca)) + #{})] + :when (and m + (contains? + caps "away-notify"))] + (deliver-to-client! (:w m) line) + (swap! seen conj mn))) + ;; extended-monitor: AWAY also goes to watchers + ;; that negotiated extended-monitor + away-notify + (notify-extended-monitors! + components nick "away-notify" line seen)))] + (if (or (empty? params) (string/blank? msg)) + (do (swap! client dissoc :away) + (notify! (str ":" src " AWAY")) + [(numeric-reply client "305" + ":You are no longer marked as being away")]) + (do (swap! client assoc :away msg) + (notify! (str ":" src " AWAY :" msg)) + [(numeric-reply client "306" + ":You have been marked as being away")])))) + +(def- +monitor-limit+ 100) + +(defn- nick-online? + [clients nick] + (let [low (string/lower-case nick)] + (boolean + (some (fn [[k _]] (= (string/lower-case k) low)) + @clients)))) + +(defn- mononline-line + [client target] + (numeric-reply client "730" (str ":" target))) + +(defn- monoffline-line + [client target] + (numeric-reply client "731" (str ":" target))) + +(defn- handle-monitor + [params client components] + (let [{:keys [clients nick-monitors]} components + sub (some-> (first params) string/upper-case) + arg (second params) + targets (when arg (string/split arg #",")) + valid? (fn [t] (and (seq t) (valid-nick? t)))] + (case sub + "+" + (let [valid-ts (filter valid? targets) + invalid-ts (remove valid? targets) + current (or (:monitor-targets @client) #{}) + new-lows (->> valid-ts + (map string/lower-case) + (remove current)) + next-set (into current new-lows)] + (cond + (> (count next-set) +monitor-limit+) + [(numeric-reply client "734" + (str +monitor-limit+ " " + (string/join "," targets) + " :Monitor list is full."))] + :else + (do (swap! client assoc :monitor-targets next-set) + (when nick-monitors + (doseq [low new-lows] + (swap! nick-monitors update low + (fnil conj #{}) client))) + (let [online (filter + #(nick-online? clients %) + valid-ts) + offline (concat + invalid-ts + (remove + #(nick-online? clients %) + valid-ts))] + (cond-> [] + (seq online) + (conj (mononline-line client + (string/join "," online))) + (seq offline) + (conj (monoffline-line client + (string/join "," offline)))))))) + + "-" + (let [removed-lows (->> targets + (map string/lower-case) + set) + current (or (:monitor-targets @client) #{}) + next-set (into #{} + (remove removed-lows current))] + (swap! client assoc :monitor-targets next-set) + (when nick-monitors + (doseq [low removed-lows] + (swap! nick-monitors update low + (fnil disj #{}) client))) + []) + + "C" + (let [current (or (:monitor-targets @client) #{})] + (swap! client assoc :monitor-targets #{}) + (when nick-monitors + (doseq [low current] + (swap! nick-monitors update low + (fnil disj #{}) client))) + []) + + "L" + (let [current (or (:monitor-targets @client) #{}) + list-line (when (seq current) + [(numeric-reply client "732" + (str ":" + (string/join "," current)))])] + (vec + (concat list-line + [(numeric-reply client "733" + ":End of MONITOR list")]))) + + "S" + (let [current (or (:monitor-targets @client) #{}) + online (filter #(nick-online? clients %) + current) + offline (remove (set online) current)] + (cond-> [] + (seq online) + (conj (mononline-line client + (string/join "," online))) + (seq offline) + (conj (monoffline-line client + (string/join "," offline))))) + + [(numeric-reply client "461" + "MONITOR :Not enough parameters")]))) + +(defn- notify-monitors! + [components nick numeric] + (when-let [nm (:nick-monitors components)] + (let [low (string/lower-case nick) + watchers (get @nm low #{})] + (doseq [watcher watchers + :let [w (:w @watcher) + wn (or (:nick @watcher) "*")] + :when w] + (deliver-to-client! w + (str ":" +server-name+ " " numeric " " + wn " :" nick)))))) + +(defn- notify-extended-monitors! + "Deliver `line` to each watcher of `nick` that negotiated + extended-monitor and the given gating cap (away-notify, setname, + account-notify, chghost), excluding any watcher already in `seen`." + [components nick gating-cap line seen] + (when-let [nm (:nick-monitors components)] + (let [low (string/lower-case nick)] + (doseq [watcher (get @nm low #{}) + :let [w (:w @watcher) + caps (or (:caps @watcher) #{}) + wn (:nick @watcher)] + :when (and w + (caps "extended-monitor") + (caps gating-cap) + (not (contains? @seen wn)))] + (deliver-to-client! w line) + (swap! seen conj wn))))) + +(defn- handle-setname + [params client components] + (let [{:keys [clients channels]} components + nick (client-target client) + src (client-prefix client) + raw (when (seq params) (string/join " " params)) + new-name (when raw + (cond-> raw + (string/starts-with? raw ":") (subs 1)))] + (cond + (or (string/blank? new-name) + (> (count new-name) 390)) + [(str "FAIL SETNAME INVALID_REALNAME :Invalid realname")] + + :else + (let [line (str ":" src " SETNAME :" new-name) + seen (atom #{nick})] + (swap! client assoc-in [:user :realname] new-name) + (when (and clients channels) + (doseq [[ch members] @channels + :when (contains? members nick) + mn members + :when (not (contains? @seen mn)) + :let [m (get @clients mn) + ca (:client-atom m) + caps (or (when ca (:caps @ca)) + #{})] + :when (and m (caps "setname"))] + (deliver-to-client! (:w m) line) + (swap! seen conj mn))) + ;; extended-monitor: SETNAME also goes to watchers + ;; with extended-monitor + setname. + (notify-extended-monitors! + components nick "setname" line seen) + [line])))) + +(defn- handle-relaymsg + [params client components] + (let [{:keys [conn clients channels]} components + nick (client-target client)] + (cond + (< (count params) 3) + [(numeric-reply client "461" + "RELAYMSG :Not enough parameters")] + + :else + (let [target (first params) + fakenick (second params) + content (string/join " " (drop 2 params)) + text (cond-> content + (string/starts-with? content ":") (subs 1)) + chan-handle (string/lower-case target) + op? (and (:ops components) + (contains? + (get @(:ops components) chan-handle) + nick)) + has-cap? (contains? (or (:caps @client) #{}) + "draft/relaymsg")] + (cond + (or (not (string/includes? fakenick "/")) + (string/includes? fakenick "!")) + [(str "FAIL RELAYMSG INVALID_NICK" + " :Invalid relay nickname")] + + (or (not (channel-handle? target)) + (not channels) + (empty? (get @channels chan-handle))) + [(numeric-reply client "403" + (str target " :No such channel"))] + + (not (contains? (get @channels chan-handle) nick)) + [(numeric-reply client "404" + (str target " :Cannot send to channel"))] + + (not (or op? has-cap?)) + [(str "FAIL RELAYMSG PRIVS_NEEDED" + " :You don't have permission to relay messages")] + + (string/blank? text) + [(numeric-reply client "412" ":No text to send")] + + :else + (let [msg-id (java.util.UUID/randomUUID) + msg-at (java.util.Date.) + raw (str ":" fakenick " PRIVMSG " target " " + content) + relay-tag (str "draft/relaymsg=" nick)] + ;; PERSIST so chathistory replays the relayed line + (when conn + (let [db (d/db conn) + channel-eid (resolve-channel db chan-handle) + chan-uuid (when channel-eid + (:papod.channel/id + (d/entity db channel-eid)))] + (when chan-uuid + @(d/transact conn + [{:db/ensure :papod.event/attrs + :db/id "new-event" + :papod.event/id msg-id + :papod.event/type "user-message" + :papod.event/source-nick fakenick + :papod.event/payload content + :papod.event/created-at msg-at + :papod.event/channel channel-eid} + [:papod.event/add-seq "new-event" chan-uuid]])))) + ;; DELIVER to channel members (excluding sender) + (let [pick-line + (fn [caps] + (let [tagged? (and caps + (or (caps "message-tags") + (caps "server-time"))) + rl? (and caps (caps "draft/relaymsg")) + extras (cond-> [] + rl? (conj relay-tag))] + (cond + tagged? (tag-line-at msg-id msg-at + extras raw) + rl? (str "@" relay-tag " " raw) + :else raw)))] + (doseq [mn (get @channels chan-handle) + :when (not= mn nick) + :let [m (when clients (get @clients mn))] + :when m] + (let [ca (:client-atom m) + caps (or (when ca (:caps @ca)) #{})] + (deliver-to-client! (:w m) (pick-line caps)))) + ;; Reply to sender (echo only when echo-message) + (let [my-caps (or (:caps @client) #{})] + (cond-> [] + (contains? my-caps "echo-message") + (conj (pick-line my-caps))))))))))) + +(defn- handle-register + [params client components] + (let [nick (:nick @client) + cracha (:cracha components) + conn (cracha-conn components) + account (first params) + email (when (>= (count params) 2) (second params)) + password (when (>= (count params) 3) + (let [p (nth params 2)] + (cond-> p + (string/starts-with? p ":") (subs 1)))) + target-acct (cond + (and account (not= account "*")) account + (and nick (not (string/blank? nick))) nick + :else (:attempted-nick @client))] + (cond + (and (not +reg-before-connect?+) + (not (:registered? @client))) + [(str "FAIL REGISTER COMPLETE_CONNECTION_REQUIRED *" + " :You must finish connecting before registering")] + + (or (< (count params) 3) (string/blank? password)) + [(numeric-reply client "461" + "REGISTER :Not enough parameters")] + + (string/blank? target-acct) + [(str "FAIL REGISTER ACCOUNT_NAME_MUST_BE_NICK *" + " :Account name must match your current nick")] + + (and account (not= account "*") + nick (not= account nick)) + [(str "FAIL REGISTER ACCOUNT_NAME_MUST_BE_NICK " + account + " :Account name must match your current nick")] + + (and +reg-email-required?+ + (or (string/blank? email) (= email "*"))) + [(str "FAIL REGISTER INVALID_EMAIL " + target-acct + " :Email address is required")] + + (or (not cracha) (not conn)) + [(str "FAIL REGISTER TEMPORARILY_UNAVAILABLE *" + " :Account registration is unavailable")] + + :else + (cond + (cracha/user-by-email conn target-acct) + [(str "FAIL REGISTER ACCOUNT_EXISTS " + target-acct + " :Account already registered")] + + ;; No-land-grabs: another live client holds this nick + (and (:clients components) + (let [low (string/lower-case target-acct)] + (some (fn [[k m]] + (and (= (string/lower-case k) low) + (not= (:client-atom m) client))) + @(:clients components)))) + [(str "FAIL REGISTER ACCOUNT_EXISTS " + target-acct + " :Account already registered")] + + :else + (let [salt (str (java.util.UUID/randomUUID)) + user-id (cracha/register! cracha target-acct + salt password) + token (ffirst + (d/q '{:find [?token] + :in [$ ?uid] + :where [[?u :cracha.user/id ?uid] + [?a :cracha.confirmation-attempt/user ?u] + [?a :cracha.confirmation-attempt/token ?token]]} + (d/db conn) user-id))] + (when token (cracha/confirm! conn token)) + (swap! client assoc :account target-acct) + [(str ":" +server-name+ " REGISTER SUCCESS " + target-acct + " :Account " target-acct + " registered")]))))) + +(defn- handle-batch-open! + [message client components] + (let [params (clean-params (:params message)) + ref (first params) + type (second params) + target (when (>= (count params) 3) + (nth params 2)) + all-tags (or (:tags message) {}) + label (get all-tags "label") + client-tags (->> all-tags + (filter (fn [[k _]] + (string/starts-with? + k "+"))) + (into {})) + batch-id (when (and ref + (string/starts-with? ref "+")) + (subs ref 1))] + (cond + (or (nil? batch-id) + (not= type "draft/multiline") + (nil? target) + (not (channel-handle? target))) + [(str "FAIL BATCH MULTILINE_INVALID :Invalid batch")] + + :else + (do + (swap! client assoc-in + [:batches batch-id] + {:type type + :target target + :label label + :client-tags client-tags + :messages []}) + nil)))) + +(defn- multiline-payload + [messages] + (let [parts + (map (fn [{:keys [tags content]}] + (let [text (cond-> content + (string/starts-with? content ":") + (subs 1)) + concat? (contains? tags + "draft/multiline-concat")] + [concat? text])) + messages)] + (->> parts + (reduce (fn [acc [concat? text]] + (cond + (empty? acc) [text] + concat? (conj (vec (butlast acc)) + (str (last acc) text)) + :else (conj acc text))) + []) + (string/join "\n")))) + +(defn- handle-batch-close! + [message client components] + (let [{:keys [conn clients channels]} components + params (clean-params (:params message)) + ref (first params) + batch-id (when (and ref + (string/starts-with? ref "-")) + (subs ref 1)) + nick (client-target client) + batch (when batch-id + (get-in @client [:batches batch-id]))] + (cond + (or (nil? batch-id) (nil? batch)) + [(str "FAIL BATCH MULTILINE_INVALID :Unknown batch")] + + :else + (let [{:keys [type target label client-tags messages]} batch + msg-id (java.util.UUID/randomUUID) + msg-at (java.util.Date.) + payload (multiline-payload messages) + label? (and label + (contains? + (or (:caps @client) #{}) + "labeled-response")) + client-tag-strs (map (fn [[k v]] + (if (or (nil? v) (= v "")) + k + (str k "=" v))) + client-tags) + ;; Persist as a single user-message + _ (when conn + (let [db (d/db conn) + channel-eid (resolve-channel db target) + chan-uuid (when channel-eid + (:papod.channel/id + (d/entity db channel-eid)))] + (when channel-eid + @(d/transact conn + [{:db/ensure :papod.event/attrs + :db/id "new-event" + :papod.event/id msg-id + :papod.event/channel channel-eid + :papod.event/type "user-message" + :papod.event/source-nick nick + :papod.event/payload (str ":" payload) + :papod.event/created-at msg-at} + [:papod.event/add-seq "new-event" chan-uuid]])))) + forward-tag? + (fn [k] + (and (not= k "batch") + (or (string/starts-with? k "+") + (= k "draft/multiline-concat")))) + ;; Build batch lines for a recipient + build-batch + (fn [recip-batch-id include-msgid? extra-tags] + (let [base-batch (str "BATCH +" recip-batch-id + " " type " " target) + open-line + (cond + include-msgid? + (tag-line-at msg-id msg-at extra-tags + base-batch) + (seq extra-tags) + (str "@" (string/join ";" extra-tags) + " " base-batch) + :else base-batch)] + (concat + [open-line] + (map (fn [{:keys [tags content]}] + (let [extras (->> tags + (filter (fn [[k _]] + (forward-tag? k))) + (map (fn [[k v]] + (if (or (nil? v) + (= v "")) + k + (str k "=" v))))) + tag-str (string/join ";" + (cons (str "batch=" recip-batch-id) + extras))] + (str "@" tag-str " :" nick + " PRIVMSG " target " " + content))) + messages) + [(str "BATCH -" recip-batch-id)]))) + ;; Send echo to sender + my-caps (or (:caps @client) #{}) + multiline? (contains? my-caps "draft/multiline") + echo-batch-id (str (java.util.UUID/randomUUID)) + echo-tags (concat + (when label? [(str "label=" label)]) + client-tag-strs) + echo-lines + (when multiline? + (build-batch echo-batch-id true echo-tags)) + ;; Build a fallback PRIVMSG for non-multiline clients + ;; First line gets msgid+time+client-tags, rest get + ;; just time+client-tags. + fallback-line + (fn [content first?] + (let [extras (if first? + client-tag-strs + client-tag-strs) + body (str ":" nick " PRIVMSG " + target " " content)] + (if first? + (tag-line-at msg-id msg-at extras body) + (str "@time=" (iso-time msg-at) + (when (seq extras) + (str ";" (string/join ";" extras))) + " " body)))) + ;; Relay to channel members + relay! + (fn [] + (when (and clients channels) + (doseq [mn (get @channels target) + :when (not= mn nick) + :let [m (get @clients mn) + ca (when m (:client-atom m)) + caps (or (some-> ca deref :caps) + #{})] + :when m] + (cond + (caps "draft/multiline") + (let [bid (str (java.util.UUID/randomUUID))] + (doseq [line (build-batch + bid true client-tag-strs)] + (deliver-to-client! (:w m) line))) + + :else + (let [non-blank (filter + (fn [{:keys [content]}] + (let [c (cond-> content + (string/starts-with? + content ":") + (subs 1))] + (seq c))) + messages) + [first-line & rest-lines] non-blank] + (when first-line + (deliver-to-client! + (:w m) + (fallback-line (:content first-line) true))) + (doseq [{:keys [content]} rest-lines] + (deliver-to-client! + (:w m) + (fallback-line content false))))))))] + (relay!) + (swap! client update :batches dissoc batch-id) + (cond + (and label? (not multiline?)) + [(str "@label=" label " ACK")] + + (contains? my-caps "echo-message") + (vec echo-lines) + + :else []))))) + +(defn- replies-for! + [message client components] + (let [command (string/upper-case (:command message)) + params (clean-params (:params message)) + tags (:tags message) + batch-tag (get tags "batch") + in-batch (and batch-tag + (get-in @client [:batches batch-tag])) + any-batch-open? (seq (:batches @client)) + concat-tag? (contains? tags "draft/multiline-concat") + content (when (and (= command "PRIVMSG") + (>= (count params) 2)) + (let [c (string/join " " (rest params))] + (cond-> c + (string/starts-with? c ":") (subs 1)))) + blank? (and content (empty? content))] + (cond + ;; Tagged with a batch ID we don't know about: per IRCv3 + ;; the message MUST be ignored (the batch was rejected or + ;; never existed). We additionally clear any other open + ;; batches when one was open, since this is a sign the + ;; client got out of sync. + (and batch-tag (not in-batch)) + (do + (when any-batch-open? + (swap! client dissoc :batches)) + []) + + ;; Concat tag on a blank message: invalid per spec. + (and in-batch concat-tag? blank?) + (do + (swap! client dissoc :batches) + [(str "FAIL BATCH MULTILINE_INVALID" + " :Concat tag with blank line")]) + + ;; Inside an open client batch — accumulate the message + ;; and don't process it yet + (and in-batch + (= command "PRIVMSG")) + (let [content-str (string/join " " (rest params)) + updated (update-in @client + [:batches batch-tag :messages] + (fnil conj []) + {:tags tags + :content content-str}) + new-msgs (get-in updated + [:batches batch-tag :messages]) + line-count (count new-msgs) + byte-count (reduce + 0 + (map (fn [{:keys [content]}] + (let [c (cond-> content + (string/starts-with? + content ":") + (subs 1))] + (alength + (.getBytes ^String c + "UTF-8")))) + new-msgs))] + (cond + (> line-count 32) + (do + (swap! client dissoc :batches) + [(str "FAIL BATCH MULTILINE_MAX_LINES 32" + " :Maximum lines exceeded")]) + + (> byte-count 4096) + (do + (swap! client dissoc :batches) + [(str "FAIL BATCH MULTILINE_MAX_BYTES 4096" + " :Maximum bytes exceeded")]) + + :else + (do + (swap! client update-in + [:batches batch-tag :messages] + (fnil conj []) + {:tags tags + :content content-str}) + nil))) + + :else + (case command + "PASS" (handle-pass params client components) + "NICK" (handle-nick params client components) + "USER" (handle-user params client components) + "PING" (handle-ping params client) + "PONG" [] + "CAP" (handle-cap params client components) + "AUTHENTICATE" (handle-authenticate params client components) + "REGISTER" (handle-register params client components) + "METADATA" (handle-metadata params client components) + "BATCH" (let [ref (first params)] + (cond + (and ref (string/starts-with? ref "+")) + (handle-batch-open! message client components) + (and ref (string/starts-with? ref "-")) + (handle-batch-close! message client components) + :else + [(str "FAIL BATCH MULTILINE_INVALID" + " :Invalid batch")])) + (cond + (= command "QUIT") + (let [nick (client-target client) + reason (or (first params) "Client quit") + {:keys [clients channels]} components] + ;; Record WHOWAS entry for registered clients + (when (and (:registered? @client) + (:whowas components)) + (let [u (:user @client)] + (swap! (:whowas components) + (fn [hist] + (let [entry + {:nick nick + :username (:username u) + :realname (:realname u) + :host "localhost" + :ts (System/currentTimeMillis)}] + (vec (take 32 (cons entry hist)))))))) + ;; Notify channel members + (when (and clients channels) + (let [quit-line (str ":" nick " QUIT :Quit: " reason)] + (doseq [[ch members] @channels + :when (contains? members nick) + member-nick members + :when (not= member-nick nick) + :let [m (get @clients member-nick)] + :when m] + (deliver-to-client! (:w m) quit-line)))) + ;; Remove from channels, ops, voiced, and clients + (when channels + (doseq [[ch _] @channels] + (swap! channels update ch disj nick))) + (when (:ops components) + (doseq [[ch _] @(:ops components)] + (swap! (:ops components) update ch disj nick))) + (when (:voiced components) + (doseq [[ch _] @(:voiced components)] + (swap! (:voiced components) + update ch disj nick))) + (when clients + (swap! clients dissoc nick)) + (swap! client assoc :quit? true) + [(str "ERROR :Closing Link: " nick + " (Quit: " reason ")")]) + + (not (:registered? @client)) + [(numeric-reply client "451" ":You have not registered")] + + ;; Commands that work without network context + (= command "AWAY") + (handle-away params client components) + + (= command "SETNAME") + (handle-setname params client components) + + (= command "MONITOR") + (handle-monitor params client components) + + (= command "NOTICE") + (handle-notice params client components) + + (= command "WHOIS") + (let [;; WHOIS supports WHOIS <target> <nick> form + qnick (if (> (count params) 1) + (second params) + (first params)) + {:keys [clients]} components + q-lower (when qnick + (string/lower-case qnick)) + [found-nick m] + (when (and qnick clients) + (first + (filter + (fn [[k _]] + (= (string/lower-case k) q-lower)) + @clients)))] + (if m + (let [ca (:client-atom m) + st (when ca @ca) + u (:user st) + uname (or (:username u) found-nick) + rname (or (:realname u) found-nick) + away (:away st) + invisible? (boolean (:invisible? st)) + asker-nick (client-target client) + ops (:ops components) + voiced (:voiced components) + chans + (when (:channels components) + (for [[ch members] + @(:channels components) + :when (and (contains? members + found-nick) + (or (not invisible?) + (= found-nick + asker-nick) + (contains? members + asker-nick))) + :let + [op? (and ops + (contains? + (get @ops ch) + found-nick)) + v? (and voiced + (contains? + (get @voiced ch) + found-nick))]] + (str (cond op? "@" + v? "+" + :else "") + ch)))] + (cond-> + [(numeric-reply client "311" + (str found-nick " " uname + " localhost * :" rname)) + (numeric-reply client "312" + (str found-nick " " +server-name+ + " :papod"))] + (:oper? st) + (conj (numeric-reply client "313" + (str found-nick + " :is an IRC operator"))) + (:bot? st) + (conj (numeric-reply client "335" + (str found-nick + " :is a bot on " +server-name+))) + away + (conj (numeric-reply client "301" + (str found-nick " :" away))) + (seq chans) + (conj (numeric-reply client "319" + (str found-nick " :" + (string/join " " + chans)))) + (:account st) + (conj (numeric-reply client "330" + (str found-nick " " (:account st) + " :is logged in as"))) + (seq (when-let [meta-atom (:metadata components)] + (get @meta-atom + (string/lower-case found-nick)))) + ((fn [r] + (let [meta-atom (:metadata components) + kv (when meta-atom + (get @meta-atom + (string/lower-case + found-nick) {}))] + (into r + (map (fn [[k v]] + (numeric-reply client "760" + (str found-nick " " + k " * :" v))) + (sort-by first kv)))))) + true + (conj (numeric-reply client "317" + (str found-nick " 0 " + (quot + (System/currentTimeMillis) + 1000) + " :seconds idle, signon time"))) + true + (conj (numeric-reply client "318" + (str found-nick + " :End of /WHOIS list"))))) + [(numeric-reply client "401" + (str (or qnick "*") " :No such nick")) + (numeric-reply client "318" + (str (or qnick "*") + " :End of /WHOIS list"))])) + + (= command "USERHOST") + (let [{:keys [clients]} components] + (if (empty? params) + [(numeric-reply client "461" + "USERHOST :Not enough parameters")] + (let [replies + (for [target params + :let [m (and clients + (get @clients target))] + :when m + :let [ca (:client-atom m) + away (when ca (:away @ca)) + sign (if away "-" "+")]] + (str target "=" sign target "@localhost"))] + [(numeric-reply client "302" + (str ":" (string/join " " replies)))]))) + + (= command "MODE") + (let [target (first params) + {:keys [clients channels]} components] + (cond + (nil? target) + [(numeric-reply client "461" + "MODE :Not enough parameters")] + + ;; User mode set/query: MODE <nick> + (not (channel-handle? target)) + (cond + (not= (string/lower-case target) + (string/lower-case (client-target client))) + [(numeric-reply client "502" + ":Cant change mode for other users")] + + (= 1 (count params)) + (let [modes (str "+" + (when (:invisible? @client) "i") + (when (:oper? @client) "o") + (when (:wallops? @client) "w") + (when (:bot? @client) "B") + (when (:no-ctcp? @client) "T"))] + [(numeric-reply client "221" modes)]) + + :else + (let [mode-str (second params) + sign (when (#{\+ \-} + (first mode-str)) + (first mode-str)) + chars (if sign (subs mode-str 1) mode-str) + adding? (not= sign \-)] + (doseq [c chars] + (case c + \i (swap! client assoc :invisible? adding?) + \T (swap! client assoc :no-ctcp? adding?) + \w (swap! client assoc :wallops? adding?) + \B (swap! client assoc :bot? adding?) + \o (when-not adding? + (swap! client assoc :oper? false)) + nil)) + [(str ":" (client-target client) + " MODE " (client-target client) + " " mode-str)])) + + ;; Channel mode query: MODE #channel + (= 1 (count params)) + (let [modes (get @(:chan-modes components) + target "+nt")] + [(numeric-reply client "324" + (str target " " modes)) + (numeric-reply client "329" + (str target " " + (quot (System/currentTimeMillis) + 1000)))]) + + ;; Channel mode set + :else + (let [nick (client-target client) + mode-str (second params) + raw-arg (nth params 2 nil) + mode-arg (when raw-arg + (cond-> raw-arg + (string/starts-with? + raw-arg ":") + (subs 1))) + adding? (or (string/starts-with? + mode-str "+") + (not (string/starts-with? + mode-str "-"))) + mode-char (last mode-str)] + (cond + ;; Channel must exist + (and channels + (empty? (get @channels target))) + [(numeric-reply client "403" + (str target " :No such channel"))] + + ;; Need op for mode changes (list queries are open) + (and channels + (not (and (#{\b \e \I} mode-char) + (nil? raw-arg))) + (not (is-op? components target nick))) + [(numeric-reply client "482" + (str target + " :You're not channel operator"))] + + ;; +o/-o: operator mode + (= \o mode-char) + (cond + (not mode-arg) + [(numeric-reply client "461" + "MODE :Not enough parameters")] + (and clients + (not (get @clients mode-arg))) + [(numeric-reply client "401" + (str mode-arg + " :No such nick/channel"))] + (and channels + (not (contains? + (get @channels target) + mode-arg))) + [(numeric-reply client "441" + (str mode-arg " " target + " :They aren't on that channel"))] + :else + (let [line (str ":" nick " MODE " target + " " mode-str " " mode-arg)] + (if adding? + (do (when (:ops components) + (swap! (:ops components) + update target + (fnil conj #{}) mode-arg)) + (when (and clients channels) + (doseq [mn (get @channels target) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! + (:w m) line))) + []) + (do (when (:ops components) + (swap! (:ops components) + update target disj mode-arg)) + (when (and clients channels) + (doseq [mn (get @channels target) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! + (:w m) line))) + [])))) + + ;; +v/-v: voice mode + (= \v mode-char) + (if-not mode-arg + [(numeric-reply client "461" + "MODE :Not enough parameters")] + (let [line (str ":" nick " MODE " target + " " mode-str " " + mode-arg)] + (when-let [voiced (:voiced components)] + (if adding? + (swap! voiced update target + (fnil conj #{}) mode-arg) + (swap! voiced update target + disj mode-arg))) + (when (and clients channels) + (doseq [mn (get @channels target) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! + (:w m) line))) + [])) + + ;; +l/-l: limit + (= \l mode-char) + (let [parsed (when (and adding? mode-arg) + (try + (Integer/parseInt + (string/trim mode-arg)) + (catch NumberFormatException _ + nil))) + valid? (or (not adding?) + (and parsed (pos? parsed)))] + (cond + (and adding? (not mode-arg)) + [(numeric-reply client "461" + "MODE :Not enough parameters")] + (not valid?) + (let [shown (if (and mode-arg + (seq mode-arg)) + mode-arg + "*")] + [(numeric-reply client "696" + target "l" shown + ":Invalid limit value")]) + :else + (let [cur (or (get @(:chan-modes + components) + target) + "+nt") + flag "l" + new (if adding? + (if (string/includes? + cur flag) + cur + (str cur flag)) + (string/replace cur flag "")) + line (str ":" nick " MODE " target + " " mode-str + (when (and adding? + mode-arg) + (str " " mode-arg)))] + (when (:chan-modes components) + (swap! (:chan-modes components) + assoc target new)) + (when-let [limits + (:chan-limits components)] + (if adding? + (swap! limits assoc target parsed) + (swap! limits dissoc target))) + (when (and clients channels) + (doseq [mn (get @channels target) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! + (:w m) line))) + []))) + + ;; Simple flag modes (n/t/m/i/s/p/c/C) + ;; Iterate every char in the mode-str so + ;; "MODE #c +sp" applies both flags. + (every? (fn [c] + (#{\n \t \m \i \s \p \c \C \+ \-} + c)) + mode-str) + (let [chars (rest mode-str) + old (or (get @(:chan-modes + components) + target) + "+nt") + new + (reduce + (fn [acc c] + (let [f (str c)] + (if adding? + (if (string/includes? acc f) + acc (str acc f)) + (string/replace acc f "")))) + old + chars) + line (str ":" nick " MODE " target + " " mode-str)] + (when (:chan-modes components) + (swap! (:chan-modes components) + assoc target new)) + (when (and clients channels) + (doseq [mn (get @channels target) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line))) + []) + + ;; +k/-k: channel key + (= \k mode-char) + (let [valid-key? + (and adding? + mode-arg + (seq mode-arg) + (not (string/includes? mode-arg + " ")))] + (cond + (and adding? (not valid-key?)) + [(numeric-reply client "696" + target "k" "*" + ":Invalid key value")] + :else + (let [cur (or (get + @(:chan-modes components) + target) + "+nt") + flag "k" + new (if adding? + (if (string/includes? + cur flag) + cur + (str cur flag)) + (string/replace cur flag "")) + line (str ":" nick " MODE " target + " " mode-str + (when (and adding? + mode-arg) + (str " " mode-arg)))] + (when (:chan-modes components) + (swap! (:chan-modes components) + assoc target new)) + (when-let [keys + (:chan-keys components)] + (if adding? + (swap! keys assoc target mode-arg) + (swap! keys dissoc target))) + (when (and clients channels) + (doseq [mn (get @channels target) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! + (:w m) line))) + []))) + + ;; +b/+e/+I: list-mask modes + ;; (b=ban, e=ban-exception, I=invite-exception) + (#{\b \e \I} mode-char) + (let [[atom-key list-num end-num end-msg] + (case mode-char + \b [:chan-bans "367" "368" + ":End of channel ban list"] + \e [:chan-excepts "348" "349" + ":End of channel exception list"] + \I [:chan-invex "346" "347" + ":End of channel invite list"]) + list-atom (get components atom-key)] + (cond + mode-arg + (let [mask mode-arg + mask-key (string/lower-case mask) + now-secs (quot + (System/currentTimeMillis) + 1000) + line (str ":" nick " MODE " target + " " mode-str + " " mask)] + (when list-atom + (if adding? + (swap! list-atom update target + (fnil assoc {}) + mask-key + {:by nick :at now-secs}) + (swap! list-atom update target + (fnil dissoc {}) mask-key))) + (when (and clients channels) + (doseq [mn (get @channels target) + :let [m (get @clients mn)] + :when m] + (deliver-to-client! (:w m) line))) + []) + + :else + (let [cur (when list-atom + (get @list-atom target {}))] + (concat + (mapv + (fn [[mask info]] + (numeric-reply client list-num + (str target " " mask " " + (or (:by info) nick) + " " + (or (:at info) + (quot + (System/currentTimeMillis) + 1000))))) + (sort-by first cur)) + [(numeric-reply client end-num + (str target " " end-msg))])))) + + :else [])))) + + (= command "WHO") + (let [target (first params) + {:keys [clients channels]} components + nick (client-target client) + mp? (contains? + (or (:caps @client) #{}) + "multi-prefix") + whox (when-let [arg (second params)] + (when (and (string? arg) + (string/starts-with? arg "%")) + (let [body (subs arg 1) + [fs t] (string/split body #"," 2)] + {:fields (set fs) :token t}))) + who-reply + (fn [chan-name mn m] + (let [ca (:client-atom m) + st (when ca @ca) + away (:away st) + acct (or (:account st) "0") + op? (and chan-name + (is-op? components + chan-name mn)) + voc? (and chan-name + (:voiced components) + (contains? + (get + @(:voiced components) + chan-name) + mn)) + flag (str (if away "G" "H") + (when (:oper? st) "*") + (when (:bot? st) "B") + (cond + (and op? voc? mp?) + "@+" + op? "@" + voc? "+")) + uname (or (:username (:user st)) mn) + rname (or (:realname (:user st)) mn)] + (if whox + (let [fs (:fields whox) + parts (cond-> [] + (fs \t) (conj (or (:token whox) "0")) + (fs \c) (conj (or chan-name "*")) + (fs \u) (conj uname) + (fs \i) (conj "255.255.255.255") + (fs \h) (conj "localhost") + (fs \s) (conj +server-name+) + (fs \n) (conj mn) + (fs \f) (conj flag) + (fs \d) (conj "0") + (fs \l) (conj "0") + (fs \a) (conj acct) + (fs \o) (conj "0")) + base (string/join " " parts)] + (numeric-reply client "354" + (if (fs \r) + (if (seq parts) + (str base " :" rname) + (str ":" rname)) + base))) + (numeric-reply client "352" + (str chan-name " " uname + " localhost " +server-name+ + " " mn " " flag + " :0 " rname)))))] + (cond + (nil? target) + [(numeric-reply client "315" + "* :End of /WHO list")] + ;; WHO #channel (case-insensitive) + (and (channel-handle? target) channels) + (let [t-lower (string/lower-case target) + [ch-name members] + (first + (filter + (fn [[k _]] + (= (string/lower-case k) t-lower)) + @channels))] + (into + (vec + (for [mn (or members []) + :let [m (when clients + (get @clients mn))] + :when m] + (who-reply (or ch-name target) mn m))) + [(numeric-reply client "315" + (str target " :End of /WHO list"))])) + ;; WHO * — all visible users + (= "*" target) + (into + (vec + (for [[mn m] (when clients @clients)] + (who-reply "*" mn m))) + [(numeric-reply client "315" + "* :End of /WHO list")]) + ;; WHO <nick> — exact, case-insensitive, + ;; or '*'/'?' wildcard mask + :else + (let [t-lower (string/lower-case target) + mask? (some #{\* \?} target) + re (->> t-lower + (map (fn [c] + (case c + \* ".*" + \? "." + (java.util.regex.Pattern/quote + (str c))))) + (apply str)) + pat (re-pattern (str "^" re "$")) + my-nick (client-target client) + shared? (fn [other] + (when channels + (some + (fn [[_ms members]] + (and (contains? members my-nick) + (contains? members other))) + @channels))) + matches (when clients + (filter + (fn [[k m]] + (and (boolean + (re-matches + pat + (string/lower-case k))) + (or (not mask?) + (= k my-nick) + (not (some-> (:client-atom m) + deref + :invisible?)) + (shared? k)))) + @clients))] + (into + (vec + (for [[mn m] matches] + (who-reply "*" mn m))) + [(numeric-reply client "315" + (str target " :End of /WHO list"))])))) + + (= command "LUSERS") + (let [{:keys [clients channels]} components + n-users (if clients + (count + (filter + (fn [[_ m]] + (some-> (:client-atom m) deref + :registered?)) + @clients)) + 0) + n-chans (if channels + (count + (filter + (fn [[_ ms]] (seq ms)) + @channels)) + 0) + n-opers (if clients + (count + (filter + (fn [[_ m]] + (some-> (:client-atom m) deref + :oper?)) + @clients)) + 0) + n-unreg (or (some-> (:n-unreg components) + deref) + 0)] + [(numeric-reply client "251" + (str ":There are " n-users + " users and 0 invisible on 1 servers")) + (numeric-reply client "252" + (str n-opers " :operator(s) online")) + (numeric-reply client "253" + (str n-unreg " :unknown connection(s)")) + (numeric-reply client "254" + (str n-chans " :channels formed")) + (numeric-reply client "255" + (str ":I have " n-users + " clients and 0 servers")) + (let [mx (or (some-> (:max-users components) + deref) + n-users)] + (numeric-reply client "265" + (str n-users " " mx + " :Current local users " n-users + ", max " mx))) + (let [mx (or (some-> (:max-users components) + deref) + n-users)] + (numeric-reply client "266" + (str n-users " " mx + " :Current global users " n-users + ", max " mx)))]) + + (= command "MOTD") + [(numeric-reply client "422" ":MOTD File is missing")] + + (= command "WHOWAS") + (cond + (empty? params) + [(numeric-reply client "431" ":No nickname given")] + :else + (let [target (first params) + count-s (second params) + limit (when count-s + (try + (Integer/parseInt count-s) + (catch NumberFormatException _ + nil))) + t-lower (string/lower-case target) + hist (when (:whowas components) + @(:whowas components)) + wild? (some #{\* \?} target) + pat (when wild? + (re-pattern + (str "^" + (->> t-lower + (map (fn [c] + (case c + \* ".*" + \? "." + (java.util.regex.Pattern/quote + (str c))))) + (apply str)) + "$"))) + matches0 (when hist + (filter + (fn [e] + (let [nl (string/lower-case (:nick e))] + (if pat + (boolean (re-matches pat nl)) + (= nl t-lower)))) + hist)) + matches (cond + (or (nil? limit) (zero? limit)) + matches0 + (pos? limit) + (take limit matches0) + :else + matches0)] + (if (seq matches) + (-> (vec + (mapcat + (fn [{:keys [nick username realname host]}] + [(numeric-reply client "314" + (str nick " " (or username nick) + " " (or host "localhost") + " * :" + (or realname nick))) + (numeric-reply client "312" + (str nick " " +server-name+ + " :papod"))]) + matches)) + (conj (numeric-reply client "369" + (str (:nick (first matches)) + " :End of WHOWAS")))) + [(numeric-reply client "406" + (str target " :There was no such nickname")) + (numeric-reply client "369" + (str target " :End of WHOWAS"))]))) + + (= command "INFO") + (let [target (first params)] + (cond + (and target + (not= target +server-name+) + (not= target "*")) + [(numeric-reply client "402" + (str target " :No such server"))] + :else + [(numeric-reply client "371" + (str ":papod " +version+ + " - IRC server")) + (numeric-reply client "374" + ":End of /INFO list")])) + + (= command "ADMIN") + (let [target (first params)] + (cond + (and target + (not= target +server-name+) + (not= target "*")) + [(numeric-reply client "402" + (str target " :No such server"))] + :else + [(numeric-reply client "256" + (str +server-name+ + " :Administrative info")) + (numeric-reply client "257" + ":Operated by the papod project") + (numeric-reply client "258" + ":See https://papod.example for details") + (numeric-reply client "259" + ":Email: papod@example.invalid")])) + + (= command "STATS") + (let [{:keys [clients channels]} components + query (some-> (first params) + string/lower-case) + n-users (if clients + (count + (filter + (fn [[_ m]] + (some-> (:client-atom m) deref + :registered?)) + @clients)) + 0) + n-chans (if channels + (count + (filter + (fn [[_ ms]] (seq ms)) + @channels)) + 0) + uptime (or (some-> (:started-at components) + (#(quot + (- (System/currentTimeMillis) + %) + 1000))) + 0) + end-line (numeric-reply client "219" + (str (or query "*") + " :End of STATS report"))] + (case query + "u" + [(numeric-reply client "242" + (str ":Server Up " + (quot uptime 86400) " days " + (mod (quot uptime 3600) 24) ":" + (mod (quot uptime 60) 60) ":" + (mod uptime 60))) + end-line] + ;; "m" — command-usage stats; not tracked, return + ;; an empty list with the terminator. + "m" [end-line] + ;; "l" — server links; we have none. + "l" [end-line] + ;; "o" — operator list; we don't expose it. + "o" [end-line] + ;; default / unknown query: return summary then end. + (do (or query) + [(numeric-reply client "250" + (str ":Highest connection count: " + n-users " (" n-users " clients) (" + n-users " connections received)")) + end-line]))) + + (= command "LINKS") + ;; We don't link to other servers — return only ourselves. + [(numeric-reply client "364" + (str +server-name+ " " +server-name+ + " :0 papod")) + (numeric-reply client "365" + "* :End of /LINKS list")] + + (= command "TRACE") + ;; TRACE on this server: list logged-in clients as 205 + ;; entries. Operator-only on most networks; we return + ;; the list to anyone (no sensitive fields exposed). + (let [{:keys [clients]} components + n-users (if clients + (count + (filter + (fn [[_ m]] + (some-> (:client-atom m) deref + :registered?)) + @clients)) + 0)] + [(numeric-reply client "206" + (str "Serv 0 0 " +server-name+ + " *!*@* :Local server")) + (numeric-reply client "262" + (str +server-name+ " " + +version+ " :End of TRACE"))]) + + (= command "USERS") + (let [{:keys [clients]} components + registered (if clients + (filter + (fn [[_ m]] + (some-> (:client-atom m) + deref :registered?)) + @clients) + [])] + (cond + (empty? registered) + [(numeric-reply client "395" + ":Nobody logged in")] + :else + (into + [(numeric-reply client "392" + ":UserID Terminal Host")] + (concat + (for [[mn _] registered] + (numeric-reply client "393" + (str ":" mn " ttypapod localhost"))) + [(numeric-reply client "394" + ":End of users")])))) + + (= command "REHASH") + (cond + (not (:oper? @client)) + [(numeric-reply client "481" + (str ":Permission Denied- You're not" + " an IRC operator"))] + :else + ;; No actual config to reload, but emit the spec'd + ;; numeric so a client can confirm the request was + ;; honored. + [(numeric-reply client "382" + (str +server-name+ " :Rehashing"))]) + + (= command "CONNECT") + ;; We don't link to other servers; oper-only command, + ;; respond with 481 for non-opers and 402 otherwise. + (cond + (not (:oper? @client)) + [(numeric-reply client "481" + (str ":Permission Denied- You're not" + " an IRC operator"))] + :else + [(numeric-reply client "402" + (str (or (first params) "*") + " :This server does not link"))]) + + (= command "TIME") + [(numeric-reply client "391" + (str +server-name+ " :" (iso-time)))] + + (= command "VERSION") + [(numeric-reply client "351" + (str +version+ " " +server-name+ " :papod")) + (numeric-reply client "005" + (str "ACCOUNTEXTBAN=a" + " AWAYLEN=200 BOT=B" + " CASEMAPPING=ascii" + " CHANLIMIT=#:100" + " CHANMODES=beI,k,l,Cnt" + " CHANNELLEN=64 CHANTYPES=#" + " ELIST= EXCEPTS=e" + " EXTBAN=~,am HOSTLEN=64" + " :are supported by this server")) + (numeric-reply client "005" + (str "INVEX=I KICKLEN=300" + " MAXLIST=b:50,e:50,I:50" + " MAXTARGETS=4 MODES=4" + " MONITOR=100 NETWORK=papod" + " NICKLEN=30 PREFIX=(ov)@+" + " STATUSMSG=@+" + " :are supported by this server")) + (numeric-reply client "005" + (str "TARGMAX=KICK:1,LIST:,WHOIS:1,PRIVMSG:4,NAMES:" + " TOPICLEN=390 USERLEN=18" + " UTF8ONLY WHOX" + " :are supported by this server"))] + + (= command "ISON") + (let [{:keys [clients]} components + online (when clients + (for [target params + :when (and (string? target) + (seq target)) + :let [low (string/lower-case target) + m (some + (fn [[k m]] + (when (= (string/lower-case k) + low) + m)) + @clients)] + :when m] + target))] + [(numeric-reply client "303" + (str ":" (string/join " " (or online []))))]) + + (= command "OPER") + (cond + (< (count params) 2) + [(numeric-reply client "461" + "OPER :Not enough parameters")] + :else + (let [oper-name (first params) + oper-pass (second params) + nick (client-target client)] + (if (and (= oper-name "operuser") + (= oper-pass "operpassword")) + (do (swap! client assoc :oper? true) + [(numeric-reply client "381" + ":You are now an IRC operator") + (str ":" +server-name+ " MODE " + nick " :+o")]) + [(numeric-reply client "464" + ":Password incorrect")]))) + + (= command "KILL") + (let [{:keys [clients]} components + target (first params) + reason-parts (rest params) + reason (when (seq reason-parts) + (cond-> (string/join " " reason-parts) + (string/starts-with? + (first reason-parts) ":") + (subs 1))) + nick (client-target client)] + (cond + (empty? params) + [(numeric-reply client "461" + "KILL :Not enough parameters")] + (not (:oper? @client)) + [(numeric-reply client "481" + ":Permission Denied- You're not an IRC operator")] + :else + (let [m (when clients (get @clients target))] + (when m + (let [ca (:client-atom m)] + (when ca + (deliver-to-client! (:w m) + (str ":" nick " KILL " target + " :" (or reason ""))) + (deliver-to-client! (:w m) + (str "ERROR :Closing Link: " + target " (Killed (" nick + " (" (or reason "") ")))")) + (swap! ca assoc :quit? true) + (try + (.close (:socket @ca)) + (catch Exception _))))) + []))) + + (= command "WALLOPS") + (let [{:keys [clients]} components + text-parts (when (seq params) + (string/join " " params)) + text (when text-parts + (cond-> text-parts + (string/starts-with? text-parts ":") + (subs 1))) + src (client-prefix client) + nick (client-target client)] + (cond + (or (empty? params) (string/blank? text)) + [(numeric-reply client "461" + "WALLOPS :Not enough parameters")] + (not (:oper? @client)) + [(numeric-reply client "481" + ":Permission Denied- You're not an IRC operator")] + :else + (let [line (str ":" src " WALLOPS :" text)] + (when clients + (doseq [[mn m] @clients + :let [ca (:client-atom m) + recv? (or (= mn nick) + (and ca (:wallops? @ca)))] + :when recv?] + (deliver-to-client! (:w m) line))) + []))) + + ;; CHGHOST: oper-only, mutates target's user@host and + ;; broadcasts ":old-prefix CHGHOST newuser newhost" to + ;; peers in shared chans that negotiated the chghost cap. + (= command "CHGHOST") + (let [{:keys [clients channels]} components + target (first params) + newuser (second params) + newhost (when (>= (count params) 3) + (let [h (nth params 2)] + (cond-> h + (string/starts-with? h ":") + (subs 1)))) + nick (client-target client)] + (cond + (< (count params) 3) + [(numeric-reply client "461" + "CHGHOST :Not enough parameters")] + + (not (:oper? @client)) + [(numeric-reply client "481" + (str ":Permission Denied- You're not" + " an IRC operator"))] + + (or (string/blank? newuser) + (string/blank? newhost) + (string/includes? newuser " ") + (string/includes? newhost " ")) + [(numeric-reply client "501" + ":Invalid CHGHOST parameters")] + + :else + (let [m (and clients (get @clients target)) + ca (when m (:client-atom m))] + (cond + (not ca) + [(numeric-reply client "401" + (str target " :No such nick"))] + + :else + (let [old-prefix + (let [c @ca + tnick (or (:nick c) target) + u (:user c) + ident (or (:username u) tnick) + host (or (:host c) "localhost")] + (str tnick "!" ident "@" host)) + line (str ":" old-prefix + " CHGHOST " newuser + " " newhost)] + (swap! ca (fn [s] + (-> s + (assoc-in [:user :username] + newuser) + (assoc :host newhost)))) + ;; Notify the target itself. + (deliver-to-client! (:w m) line) + ;; Notify peers in shared channels that + ;; negotiated chghost. + (when (and clients channels) + (let [seen (atom #{target})] + (doseq [[ch members] @channels + :when (contains? members target) + mn members + :when (not (contains? @seen mn)) + :let [pm (get @clients mn) + pca (:client-atom pm) + pcaps (or (some-> pca + deref :caps) + #{})] + :when (and pm + (contains? + pcaps "chghost"))] + (deliver-to-client! (:w pm) line) + (swap! seen conj mn)))) + [(numeric-reply client "396" + (str newuser "@" newhost + " :is now your visible host"))]))))) + + (or (= command "HELP") (= command "HELPOP")) + (let [subject (or (first params) "*") + nick (client-target client)] + [(numeric-reply client "704" + (str subject " :papod " +version+ + " — see <https://modern.ircdocs.horse/>")) + (numeric-reply client "705" + (str subject " :papod is a small Clojure IRC server" + " implementing the Modern IRC and IRCv3 specs.")) + (numeric-reply client "706" + (str subject " :End of /" command " response"))]) + + (= command "INVITE") + (let [{:keys [clients channels]} components + p1 (first params) + p2 (second params) + ;; RFC1459 used "INVITE <channel> <nick>"; + ;; Modern uses "INVITE <nick> <channel>". Accept + ;; either by detecting a channel prefix on p1. + [target handle] + (if (and p1 (channel-handle? p1)) + [p2 p1] + [p1 p2]) + nick (client-target client) + chan-exists? + (and channels handle + (seq (get @channels handle)))] + (cond + (< (count params) 2) + [(numeric-reply client "461" + "INVITE :Not enough parameters")] + + (and clients + (not (get @clients target))) + [(numeric-reply client "401" + (str target " :No such nick/channel"))] + + (and chan-exists? + (not (contains? (get @channels handle) nick))) + [(numeric-reply client "442" + (str handle " :You're not on that channel"))] + + (and chan-exists? + (let [m (chan-modes-for components handle)] + (and (string/includes? m "i") + (not (is-op? components handle nick))))) + [(numeric-reply client "482" + (str handle + " :You're not channel operator"))] + + (and chan-exists? + (contains? (get @channels handle) target)) + [(numeric-reply client "443" + (str target " " handle + " :is already on channel"))] + + :else + (do + (when-let [invites (:invites components)] + (swap! invites update handle + (fnil conj #{}) target)) + ;; Deliver INVITE to the invitee, preserving + ;; the input param order (RFC1459-style + ;; "INVITE #chan bar" stays as-is). + (let [base + (str ":" (client-prefix client) + " INVITE " p1 " " p2) + acct (or (:account @client) "*") + tagged-base + (str "@account=" acct " " base) + inviting (numeric-reply client "341" + (str target " " handle))] + (when-let [m (and clients + (get @clients target))] + (let [ca (:client-atom m) + caps (when ca + (or (:caps @ca) #{})) + line (if (caps "account-tag") + tagged-base + base)] + (deliver-to-client! (:w m) line))) + ;; invite-notify: also send the INVITE line + ;; to channel ops who negotiated the cap + ;; (excluding the inviter and the invitee). + (when (and chan-exists? clients + (:ops components)) + (let [op-set (or (get @(:ops components) + handle) #{})] + (doseq [op-nick op-set + :when (and (not= op-nick nick) + (not= op-nick + target)) + :let [m (get @clients op-nick) + ca (:client-atom m) + caps (when ca + (or (:caps @ca) + #{}))] + :when (and m + (caps "invite-notify"))] + (deliver-to-client! (:w m) + (if (caps "account-tag") + tagged-base base))))) + ;; When the channel does not exist, also + ;; echo the INVITE back to the inviter + ;; (RFC1459 behavior). When it exists, + ;; only RPL_INVITING is sent. + (if chan-exists? + [inviting] + [base inviting]))))) + + ;; Network-scoped commands + (not (:network-id @client)) + [(numeric-reply client "451" + ":No network selected (use PASS <network-uuid>)")] + + :else + (case command + "PRIVMSG" (let [tgt (first params)] + (if (and tgt + (string/includes? tgt ",")) + (vec + (mapcat + #(handle-privmsg + (assoc params 0 %) + (:tags message) + client components) + (string/split tgt #","))) + (handle-privmsg params + (:tags message) + client components))) + "RELAYMSG" (handle-relaymsg params client components) + "JOIN" (handle-join params client components) + "RENAME" (handle-rename params client components) + "PART" (handle-part params client components) + "TOPIC" (handle-topic params client components) + "KICK" (handle-kick params client components) + "CHATHISTORY" (handle-chathistory params client components) + "REDACT" (handle-redact params client components) + "EDIT" (handle-edit params client components) + "TAGMSG" (handle-tagmsg message client components) + "MARKREAD" (handle-markread params client components) + "METADATA" (handle-metadata params client components) + "LIST" (let [filter-ch (first params) + self (client-target client) + chans (when (:channels components) + @(:channels components)) + modes-of (fn [ch] + (or (get + @(:chan-modes + components) + ch) + "+nt")) + hidden? (fn [ch members] + (and (not + (contains? + members self)) + (let [m (modes-of + ch)] + (or + (string/includes? + m "s") + (string/includes? + m "p"))))) + active (filter + (fn [[ch members]] + (and (seq members) + (not (hidden? + ch members)) + (or (nil? filter-ch) + (= ch filter-ch)))) + chans)] + (conj + (vec + (for [[ch members] active] + (numeric-reply client "322" + (str ch " " + (count members) " :")))) + (numeric-reply client "323" + ":End of /LIST"))) + "NAMES" (let [raw-arg (first params) + chans (:channels components) + caps0 (or (:caps @client) #{}) + mp? (contains? + caps0 "multi-prefix") + uh? (contains? + caps0 + "userhost-in-names") + one-chan + (fn [ch] + (let [present? + (and chans + (get @chans ch)) + members + (when present? + (names-for + components ch + :multi-prefix? mp? + :userhost? uh?)) + sym (chan-symbol + components ch)] + (cond-> + [] + members + (conj (numeric-reply + client "353" + (str sym " " + ch + " :" + members))))))] + (cond + ;; NAMES with arg (one or comma-sep) + raw-arg + (let [handles + (string/split raw-arg #",") + per-chan + (vec + (mapcat one-chan + handles))] + (conj per-chan + (numeric-reply client "366" + (str raw-arg + " :End of /NAMES list")))) + ;; NAMES without args — visible + ;; channels only (skip +s/+p the + ;; requester is not on) + chans + (let [self (client-target + client) + visible? + (fn [[ch members]] + (let [m (chan-modes-for + components + ch)] + (or (contains? + members self) + (and + (not + (string/includes? + m "s")) + (not + (string/includes? + m "p")))))) + replies + (vec + (mapcat + (fn [[ch _]] + (one-chan ch)) + (filter visible? + @chans)))] + (conj replies + (numeric-reply client "366" + "* :End of /NAMES list"))) + :else + [(numeric-reply client "366" + "* :End of /NAMES list")])) + [(numeric-reply client "421" + (str command " :Unknown command"))])))))) + +(defn- send-replies! + [replies w] + (doseq [r replies] + (let [bytes (.getBytes (str r +delimiter+) +charset+)] + (.write w bytes 0 (count bytes)))) + (.flush w)) + +(defn- add-label-tag + [line label] + (if (string/starts-with? line "@") + (let [sp (.indexOf line " ")] + (str "@label=" label ";" + (subs line 1 sp) + (subs line sp))) + (str "@label=" label " " line))) + +(defn- add-batch-tag + [line batch-id] + (if (string/starts-with? line "@") + (let [sp (.indexOf line " ")] + (str "@batch=" batch-id ";" + (subs line 1 sp) + (subs line sp))) + (str "@batch=" batch-id " " line))) + +(defn- handle-message! + [message w client components] + (let [label (get (:tags message) "label") + has-lr? (and label + (contains? (or (:caps @client) #{}) + "labeled-response")) + replies (replies-for! message client components)] + (cond + ;; Deferred (e.g., inside an open client batch) — no ACK + (nil? replies) + nil + + ;; No label or no labeled-response cap — send as-is + (not has-lr?) + (send-replies! replies w) + + ;; No replies — send ACK with label + (empty? replies) + (send-replies! + [(str "@label=" label " ACK")] + w) + + ;; Single reply — add label tag directly + (= 1 (count replies)) + (send-replies! + [(add-label-tag (first replies) label)] + w) + + ;; Multiple replies — wrap in BATCH + :else + (let [batch-id (str (java.util.UUID/randomUUID))] + (send-replies! + (into + [(str "@label=" label " BATCH +" batch-id + " labeled-response")] + (concat + (map (fn [r] + (add-batch-tag r batch-id)) + replies) + [(str "BATCH -" batch-id)])) + w))))) + +(defn- byte-length + [^String s] + (alength (.getBytes s "UTF-8"))) + +(defn- input-too-long? + [^String raw] + (let [tagged? (.startsWith raw "@") + sp (when tagged? (.indexOf raw " ")) + tags (when (and tagged? (pos? sp)) + (subs raw 1 sp)) + body (cond + (not tagged?) raw + (pos? sp) (subs raw (inc sp)) + :else "") + tag-len (if tags (byte-length tags) 0) + body-len (byte-length body)] + (or (and tagged? (> tag-len 4094)) + (> body-len 8190)))) + +(defn- has-invalid-utf8? + [^String s] + (.contains s "�")) + +(defn- extract-label + [^String raw] + (when (.startsWith raw "@") + (let [sp (.indexOf raw " ") + tags (when (pos? sp) (subs raw 1 sp))] + (when tags + (some (fn [pair] + (let [parts (string/split pair #"=" 2)] + (when (= "label" (first parts)) + (second parts)))) + (string/split tags #";")))))) + +(defn- process-message! + [raw-message w client components] + (cond + (input-too-long? raw-message) + (let [nick (or (:nick @client) "*")] + (deliver-to-client! w + (str ":" +server-name+ " 417 " nick + " :Input line was too long"))) + + (has-invalid-utf8? raw-message) + (let [label (extract-label raw-message) + ;; Best-effort command extraction so the FAIL context + ;; matches the originating command (e.g. METADATA SET). + stripped (cond-> raw-message + (string/starts-with? raw-message "@") + (subs (inc (.indexOf raw-message " ")))) + stripped (cond-> stripped + (string/starts-with? stripped ":") + (subs (inc (.indexOf stripped " ")))) + first-word (let [s (.indexOf stripped " ")] + (if (pos? s) + (subs stripped 0 s) + stripped)) + ctx (string/upper-case first-word) + ctx (if (re-matches #"[A-Z]+" ctx) ctx "*") + line (str (when label (str "@label=" label " ")) + ":" +server-name+ + " FAIL " ctx " INVALID_UTF8 :Message" + " rejected, contains invalid UTF-8")] + (deliver-to-client! w line)) + + :else + (let [[message err] (parse-message raw-message)] + (when-not err + (handle-message! message w client components))))) + +(defn- process-input! + [input w client components] + (let [{:keys [status input raw-message]} (get-raw-message input)] + (cond + (= status :needs-more) input + (:quit? @client) input + :else + (do + (when raw-message + (process-message! raw-message w client components)) + (if (:quit? @client) + input + (recur input w client components)))))) + +(defn- init! + [db-uri cracha-state] + (d/create-database db-uri) + (let [conn (d/connect db-uri) + _ @(d/transact conn schema) + process-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.process/attrs + :papod.process/id process-id + :papod.process/pid (.pid (java.lang.ProcessHandle/current)) + :papod.process/hostname (.getHostName + (java.net.InetAddress/getLocalHost)) + :papod.process/started-at (java.util.Date.)}])] + {:conn conn + :cracha cracha-state + :process-id process-id + :clients (atom {}) + :account-clients (atom {}) + :nick-monitors (atom {}) + :channels (atom {}) + :ops (atom {}) + :voiced (atom {}) + :chan-modes (atom {}) + :chan-keys (atom {}) + :chan-limits (atom {}) + :chan-bans (atom {}) + :chan-excepts (atom {}) + :chan-invex (atom {}) + :metadata (atom {}) + :metadata-subs (atom {}) + :invites (atom {}) + :whowas (atom []) + :max-users (atom 0) + :n-unreg (atom 0) + :started-at (System/currentTimeMillis) + :user-markers (atom {})})) + +(defconst- +idle-timeout-ms+ + (if-let [t (System/getenv "PAPOD_IDLE_TIMEOUT")] + (Long/parseLong t) + 300000)) + +(declare client-loop-body!) + +(defn- client-loop! + [socket components] + (let [_ (try + (let [s (.socket socket)] + (when s + (.setSoTimeout s (int +idle-timeout-ms+)))) + (catch Exception _)) + bis (java.io.BufferedInputStream. + (java.nio.channels.Channels/newInputStream socket) + 4096) + proxy-authority (try (read-proxy-v2-authority! bis) + (catch Exception _ nil)) + authority (or (and (string? proxy-authority) + (not (string/blank? proxy-authority)) + proxy-authority) + (and (not (string/blank? +network-name-fallback+)) + +network-name-fallback+)) + network-id (when authority + (try (find-or-create-network! + (:conn components) authority) + (catch Exception _ nil))) + r (java.io.InputStreamReader. bis ^String +charset+) + w (java.nio.channels.Channels/newOutputStream socket) + b (char-array +buffer-size+) + conn-id (java.util.UUID/randomUUID) + client (atom {:nick nil :user nil :pass nil :registered? false + :w w :connection-id conn-id + :network-id network-id + :socket socket})] + ;; No authority resolved (no PROXY header and no PAPOD_NETWORK_NAME + ;; fallback): refuse the connection. papod no longer carries a + ;; default network — every client must declare which network they + ;; are connecting to via the SNI surfaced by untls. + (if-not network-id + (do (try + (.write w (.getBytes + "ERROR :Closing link: (No network)\r\n" + ^String +charset+)) + (.flush w) + (catch Exception _)) + (try (.close socket) (catch Exception _))) + (client-loop-body! r w b conn-id client components)))) + +(defn- client-loop-body! + [^java.io.Reader r w b conn-id client components] + (let [socket (:socket @client)] + ;; Record connection start + (when-let [n (:n-unreg components)] + (swap! n inc)) + (when-let [conn (:conn components)] + @(d/transact conn + [{:db/ensure :papod.connection/attrs + :papod.connection/id conn-id + :papod.connection/process [:papod.process/id + (:process-id components)] + :papod.connection/created-at (java.util.Date.)}])) + (try + (loop [acc ""] + (let [n (.read r b)] + (when (pos? n) + (let [new-acc (process-input! + (str acc (String. b 0 n)) + w client components)] + (when-not (:quit? @client) + (recur new-acc)))))) + (catch Exception _) + (finally + ;; If still unregistered, decrement count + (when (and (not (:registered? @client)) + (:n-unreg components)) + (swap! (:n-unreg components) + (fn [v] (max 0 (dec v))))) + ;; Clean up in-memory state FIRST (before slow I/O) + (when-let [nick (:nick @client)] + ;; Record WHOWAS history (skip if QUIT already did) + (when (and (:registered? @client) + (not (:quit? @client)) + (:whowas components)) + (let [u (:user @client)] + (swap! (:whowas components) + (fn [hist] + (let [entry + {:nick nick + :username (:username u) + :realname (:realname u) + :host "localhost" + :ts (System/currentTimeMillis)}] + (vec (take 32 (cons entry hist)))))))) + (when-let [acct (:account @client)] + (when (:account-clients components) + (swap! (:account-clients components) + update acct + (fnil disj #{}) client))) + ;; Only remove from :clients if THIS atom is the + ;; canonical session for the nick (other multi- + ;; session siblings might still be live). + (when (:clients components) + (let [entry (get @(:clients components) nick)] + (when (or (nil? entry) + (= client (:client-atom entry))) + (swap! (:clients components) dissoc nick) + ;; Notify monitors only when the nick is + ;; actually going offline (no other session + ;; remains for it). + (when (:registered? @client) + (notify-monitors! components nick + "731"))))) + ;; Drop our own monitor subscriptions. + (when-let [nm (:nick-monitors components)] + (doseq [low (or (:monitor-targets @client) + #{})] + (swap! nm update low (fnil disj #{}) client))) + (when (:channels components) + (doseq [[ch _] @(:channels components)] + (swap! (:channels components) update ch disj nick))) + (when (:ops components) + (doseq [[ch _] @(:ops components)] + (swap! (:ops components) update ch disj nick))) + (when (:voiced components) + (doseq [[ch _] @(:voiced components)] + (swap! (:voiced components) + update ch disj nick)))) + (try (.close socket) (catch Exception _)) + ;; Record connection end + (when-let [conn (:conn components)] + (try + @(d/transact conn + [[:db/add [:papod.connection/id conn-id] + :papod.connection/finished-at (java.util.Date.)]]) + (catch Exception _))))))) + +(def- +socket-path+ + (or (System/getenv "PAPOD_SOCKET") + "papod.socket")) + +(def- +tcp-port+ + (when-let [p (System/getenv "PAPOD_TCP_PORT")] + (Long/parseLong p))) + +(defn- start + [components] + (let [server-channel + (if +tcp-port+ + (let [ch (ServerSocketChannel/open)] + (.bind ch (InetSocketAddress. "0.0.0.0" (int +tcp-port+))) + ch) + (let [ch (ServerSocketChannel/open + StandardProtocolFamily/UNIX)] + (Files/deleteIfExists + (java.nio.file.Path/of + +socket-path+ (into-array String []))) + (.bind ch (UnixDomainSocketAddress/of +socket-path+)) + ch))] + (while true + (let [client (.accept server-channel)] + (.start (Thread/ofVirtual) + #(client-loop! client components)))))) + +(defn -main + [& args] + (let [db-uri (or (first args) + (str "datomic:mem://papod-" (java.util.UUID/randomUUID))) + fiinha-state (fiinha/initdb! + (or (second args) + (str "datomic:mem://fiinha-" + (java.util.UUID/randomUUID)))) + cracha-state (cracha/init! + (or (nth args 2 nil) + (str "datomic:mem://cracha-" + (java.util.UUID/randomUUID))) + fiinha-state) + components (init! db-uri cracha-state)] + ;; Networks are no longer pre-created. The authority surfaced by + ;; untls (or the PAPOD_NETWORK_NAME fallback) is looked up — and + ;; created on first use — at connect time inside client-loop!. + (start components))) diff --git a/src/papod.go b/src/papod.go deleted file mode 100644 index b50729b..0000000 --- a/src/papod.go +++ /dev/null @@ -1,4585 +0,0 @@ -package papod - -import ( - "bufio" - "bytes" - "database/sql" - "errors" - "fmt" - "io" - "log/slog" - "net" - "os" - "regexp" - "strings" - "sync" - "time" - - "cracha" - "fiinha" - "golite" - "uuid" - "pds" - "stm" - g "gobang" -) - - - -const ( - defaultPrefix = "papod" - rollbackErrorFmt = "rollback error: %w; while executing: %w" - - NEW_CHANNEL = "new-channel" - - pingFrequency = time.Duration(30) * time.Second - pongMaxLatency = time.Duration(5) * time.Second -) - - - -type dbconfigT struct{ - shared *sql.DB - dbpath string - prefix string -} - -type queryT struct{ - write string - read string -} - -type queriesT struct{ - createUser func(newUserT) (userT, error) - userByUUID func(uuid.UUID) (userT, error) - updateUser func(userT) error - deleteUser func(uuid.UUID) error - addNetwork func(userT, newNetworkT, uuid.UUID) (networkT, error) - getNetwork func(userT, uuid.UUID) (networkT, error) - networks func(userT, func(networkT) error) error - setNetwork func(memberT, networkT) error - nipNetwork func(memberT) error - membership func(userT, networkT) (memberT, error) - addMember func(memberT, newMemberT) (memberT, error) - addRole func(memberT, string, memberT) error - dropRole func(memberT, string, memberT) error - showMember func(memberT, uuid.UUID) (memberT, error) - members func(memberT, func(memberT) error) error - editMember func(memberT, memberT) error - dropMember func(memberT, uuid.UUID) error - addChannel func(memberT, newChannelT) (channelT, error) - chanByName func(memberT, string) (channelT, error) - channels func(memberT, func(channelT) error) error - setChannel func(memberT, channelT) error - endChannel func(memberT, uuid.UUID) error - join func(memberT, uuid.UUID) error - part func(memberT, channelT) error - names func(memberT, uuid.UUID, func(memberT) error) error - addEvent func(newEventT) (eventT, error) - allAfter func(memberT, uuid.UUID, func(eventT) error) error - logMessage func(userT, messageT) error - close func() error -} - -type newUserT struct{ - uuid uuid.UUID - username string - displayName string -} - -type userT struct{ - id int64 - timestamp time.Time - uuid uuid.UUID - username string - displayName string - pictureID *uuid.UUID -} - -type NetworkType string -const ( - NetworkType_Public NetworkType = "public" - NetworkType_Private NetworkType = "private" - NetworkType_Unlisted NetworkType = "unlisted" -) - -type MemberStatus string -const ( - MemberStatus_Active MemberStatus = "active" - MemberStatus_Inactive MemberStatus = "inactive" - MemberStatus_Removed MemberStatus = "removed" -) - -type newNetworkT struct{ - uuid uuid.UUID - name string - description string - type_ NetworkType -} - -type networkT struct{ - id int64 - timestamp time.Time - uuid uuid.UUID - name string - description string - type_ NetworkType -} - -type newMemberT struct{ - userID uuid.UUID - memberID uuid.UUID - username string -} - -type memberT struct{ - id int64 - timestamp time.Time - uuid uuid.UUID - username string - displayName string - pictureID *uuid.UUID - status MemberStatus - roles []string -} - -type newChannelT struct{ - id int64 - timestamp time.Time - uuid uuid.UUID - publicName *string - label string - description string - virtual bool -} - -type channelT struct{ - id int64 - timestamp time.Time - uuid uuid.UUID - publicName *string - label string - description string - virtual bool -} - -type SourceType string -const ( - SourceType_Logon SourceType = "logon" -) - -type sourceT struct{ - uuid uuid.UUID - type_ SourceType - metadata *map[string]interface{} -} - -type EventType string -const ( - EventType_UserJoin EventType = "user-join" - EventType_UserMessage EventType = "user-message" -) - -type newEventT struct{ - eventID uuid.UUID - channelID uuid.UUID - source sourceT - type_ EventType - payload string - metadata *map[string]interface{} -} - -type eventT struct{ - id int64 - timestamp time.Time - uuid uuid.UUID - channelID uuid.UUID - source sourceT - type_ EventType - payload string - metadata *map[string]interface{} -} - -type eventEntryT struct{ - event eventT - previous *eventT - isFirst bool -} - -type messageT struct{ - prefix string - command string - params []string - raw string -} - -type replyT struct{ - command string - params []string -} - -type listenersT struct{ - daemon net.Listener - commander net.Listener - close func() error -} - -type consumerT struct{ - topic string - name string - // FIXME: use generic to avoid circular reference? - handlerFn func(papodT) func(fiinha.Message) error -} - -type netConnI interface{ - Write(p []byte) (n int, err error) - Close() error -} - -type connectionT struct{ - uuid uuid.UUID - user *userT - conn netConnI - send func(messageT) -} - -type metricsT struct{ - activeConnections g.Gauge - nicksInChannel g.Gauge - sendToClientError func(...any) - receivedMessage func(...any) - sentReply func(...any) -} - -type stateMutableT struct{ - // disconnect func(*connectionT) - subscribe func(string, []string) -} - -type stateT struct{ - members *pds.Map[string, []string] - users *pds.Map[string, []uuid.UUID] - connections *pds.Map[uuid.UUID, connectionT] -} - -// TODO: key for members should be the channelID, not its name -type stateMutableDataT struct{ - connections map[uuid.UUID]*connectionT - users map[string][]uuid.UUID - members map[string]map[string][]uuid.UUID -} - -type papodT struct{ - auth cracha.IAuth - queue fiinha.IQueue - queries queriesT - listeners listenersT - consumers []consumerT - stateMutable stateMutableT - state *stm.AtomT[*stateT] - metrics metricsT - // logger g.Logger -} - -type IPapod interface{ - Start() error - Close() error -} - - - -func serialized[A any, B any](callback func(...A) B) (func(...A) B, func()) { - in := make(chan []A) - out := make(chan B) - - closed := false - var ( - closeWg sync.WaitGroup - closeMutex sync.Mutex - ) - closeWg.Add(1) - - go func() { - for input := range in { - out <- callback(input...) - } - close(out) - closeWg.Done() - }() - - fn := func(input ...A) B { - in <- input - return (<- out) - } - - closeFn := func() { - closeMutex.Lock() - defer closeMutex.Unlock() - if closed { - return - } - close(in) - closed = true - closeWg.Wait() - } - - return fn, closeFn -} - -func execSerialized(query string, db *sql.DB) (func(...any) error, func()) { - return serialized(func(args ...any) error { - return inTx(db, func(tx *sql.Tx) error { - _, err := tx.Exec(query, args...) - return err - }) - }) -} - -func tryRollback(tx *sql.Tx, err error) error { - rollbackErr := tx.Rollback() - if rollbackErr != nil { - return fmt.Errorf( - rollbackErrorFmt, - rollbackErr, - err, - ) - } - - return err -} - -func inTx(db *sql.DB, fn func(*sql.Tx) error) error { - tx, err := db.Begin() - if err != nil { - return err - } - - err = fn(tx) - if err != nil { - return tryRollback(tx, err) - } - - err = tx.Commit() - if err != nil { - return tryRollback(tx, err) - } - - return nil -} - -/// "papod_users".uuid is the same as cracha_users.uuid. Not a foreign key to -/// allow them to live in different physical locations. Adding it here feels -/// less like an optimization related decision, and more of a coupling one. The -/// way that New() works now uses the same databasePath for the fiinha.IQueue -/// *and* cracha.IAuth, but cracha in no way exposes where it stores the user -/// UUID or how the it is handled. This has similarities to how events here -/// don't reference the fiinha.Message.ID via foreign keys either. They're -/// treated only as opaque IDs. -func createTablesSQL(prefix string) queryT { - const tmpl_write = ` - -- TODO: unconfirmed premise: statements within a trigger are - -- part of the transaction that caused it, and so are - -- atomic. - -- See also: - -- https://stackoverflow.com/questions/77441888/ - -- https://stackoverflow.com/questions/30511116/ - - CREATE TABLE IF NOT EXISTS "%s_users" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - -- provided by cracha - user_uuid BLOB NOT NULL UNIQUE, - username TEXT NOT NULL, - display_name TEXT NOT NULL, - picture_uuid BLOB UNIQUE, - deleted INT NOT NULL CHECK(deleted IN (0, 1)) - ) STRICT; - CREATE TABLE IF NOT EXISTS "%s_user_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - user_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'username', - 'display_name', - 'picture_uuid', - 'deleted' - ) - ), - value_text TEXT, - value_blob BLOB, - value_bool INT CHECK(value_bool IN (0, 1)), - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - CREATE TRIGGER IF NOT EXISTS "%s_user_new" - AFTER INSERT ON "%s_users" - BEGIN - INSERT INTO "%s_user_changes" ( - user_id, attribute, value_text, op - ) VALUES - (NEW.id, 'username', NEW.username, true), - (NEW.id, 'display_name', NEW.display_name, true) - ; - INSERT INTO "%s_user_changes" ( - user_id, attribute, value_bool, op - ) VALUES - (NEW.id, 'deleted', NEW.deleted, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_user_new_picture_uuid" - AFTER INSERT ON "%s_users" - WHEN NEW.picture_uuid IS NOT NULL - BEGIN - INSERT INTO "%s_user_changes" ( - user_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_user_update_username" - AFTER UPDATE ON "%s_users" - WHEN OLD.username != NEW.username - BEGIN - INSERT INTO "%s_user_changes" ( - user_id, attribute, value_text, op - ) VALUES - (NEW.id, 'username', OLD.username, false), - (NEW.id, 'username', NEW.username, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_user_update_display_name" - AFTER UPDATE ON "%s_users" - WHEN OLD.display_name != NEW.display_name - BEGIN - INSERT INTO "%s_user_changes" ( - user_id, attribute, value_text, op - ) VALUES - (NEW.id, 'display_name', OLD.display_name, false), - (NEW.id, 'display_name', NEW.display_name, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_user_add_picture_uuid" - AFTER UPDATE ON "%s_users" - WHEN ( - OLD.picture_uuid IS NULL AND - NEW.picture_uuid IS NOT NULL - ) - BEGIN - INSERT INTO "%s_user_changes" ( - user_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_user_remove_picture_uuid" - AFTER UPDATE ON "%s_users" - WHEN ( - OLD.picture_uuid IS NOT NULL AND - NEW.picture_uuid IS NULL - ) - BEGIN - INSERT INTO "%s_user_changes" ( - user_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', OLD.picture_uuid, false) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_user_update_picture_uuid" - AFTER UPDATE ON "%s_users" - WHEN ( - OLD.picture_uuid IS NOT NULL AND - NEW.picture_uuid IS NOT NULL AND - OLD.picture_uuid != NEW.picture_uuid - ) - BEGIN - INSERT INTO "%s_user_changes" ( - user_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', OLD.picture_uuid, false), - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_user_update_deleted" - AFTER UPDATE ON "%s_users" - WHEN OLD.deleted != NEW.deleted - BEGIN - INSERT INTO "%s_user_changes" ( - user_id, attribute, value_bool, op - ) VALUES - (NEW.id, 'deleted', OLD.deleted, false), - (NEW.id, 'deleted', NEW.deleted, true) - ; - END; - - CREATE TABLE IF NOT EXISTS "%s_sessions" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - -- provided by cracha - session_uuid BLOB NOT NULL UNIQUE, - user_id INTEGER NOT NULL - REFERENCES "%s_users"(id), - finished_at TEXT - ); - CREATE TABLE IF NOT EXISTS "%s_connections" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - uuid BLOB NOT NULL UNIQUE, - finished_at TEXT - ); - CREATE TABLE IF NOT EXISTS "%s_logons" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - session_id INTEGER NOT NULL - REFERENCES "%s_sessions"(id), - connection_id INTEGER NOT NULL - REFERENCES "%s_connections"(id), - UNIQUE (session_id, connection_id) - ) STRICT; - - CREATE TABLE IF NOT EXISTS "%s_networks" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - uuid BLOB NOT NULL UNIQUE, - name TEXT NOT NULL, - description TEXT NOT NULL, - type TEXT NOT NULL CHECK( - type IN ('public', 'private', 'unlisted') - ), - deleted INT NOT NULL CHECK(deleted IN (0, 1)) - ) STRICT; - CREATE INDEX IF NOT EXISTS "%s_networks_type" - ON "%s_networks"(type); - CREATE TABLE IF NOT EXISTS "%s_network_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - network_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'name', - 'description', - 'type', - 'deleted', - 'logon_id' -- FIXME - ) - ), - value TEXT NOT NULL, - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - CREATE TRIGGER IF NOT EXISTS "%s_network_new" - AFTER INSERT ON "%s_networks" - BEGIN - INSERT INTO "%s_network_changes" ( - network_id, attribute, value, op - ) VALUES - (NEW.id, 'name', NEW.name, true), - (NEW.id, 'description', NEW.description, true), - (NEW.id, 'type', NEW.type, true), - (NEW.id, 'deleted', NEW.deleted, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_network_update_name" - AFTER UPDATE ON "%s_networks" - WHEN OLD.name != NEW.name - BEGIN - INSERT INTO "%s_network_changes" ( - network_id, attribute, value, op - ) VALUES - (NEW.id, 'name', OLD.name, false), - (NEW.id, 'name', NEW.name, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_network_update_description" - AFTER UPDATE ON "%s_networks" - WHEN OLD.description != NEW.description - BEGIN - INSERT INTO "%s_network_changes" ( - network_id, attribute, value, op - ) VALUES - (NEW.id, 'description', OLD.description, false), - (NEW.id, 'description', NEW.description, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_network_update_type" - AFTER UPDATE ON "%s_networks" - WHEN OLD.description != NEW.description - BEGIN - INSERT INTO "%s_network_changes" ( - network_id, attribute, value, op - ) VALUES - (NEW.id, 'type', OLD.type, false), - (NEW.id, 'type', NEW.type, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_network_update_deleted" - AFTER UPDATE ON "%s_networks" - WHEN OLD.deleted != NEW.deleted - BEGIN - INSERT INTO "%s_network_changes" ( - network_id, attribute, value, op - ) VALUES - (NEW.id, 'deleted', OLD.deleted, false), - (NEW.id, 'deleted', NEW.deleted, true) - ; - END; - - CREATE TABLE IF NOT EXISTS "%s_members" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - uuid BLOB NOT NULL UNIQUE, - network_id INTEGER NOT NULL - REFERENCES "%s_networks"(id), - user_id INTEGER NOT NULL, - username TEXT NOT NULL, - display_name TEXT NOT NULL, - picture_uuid BLOB UNIQUE, - status TEXT NOT NULL CHECK( - status IN ('active', 'inactive', 'removed') - ), - active_uniq TEXT CHECK( - active_uniq IN ('active', NULL) - ), - UNIQUE (network_id, username, active_uniq), - UNIQUE (network_id, user_id) - ) STRICT; - CREATE TABLE IF NOT EXISTS "%s_member_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - member_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'username', - 'display_name', - 'picture_uuid', - 'status', - 'logon_id' -- FIXME - ) - ), - value_text TEXT, - value_blob BLOB, - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - CREATE TRIGGER IF NOT EXISTS "%s_member_new" - AFTER INSERT ON "%s_members" - BEGIN - INSERT INTO "%s_member_changes" ( - member_id, attribute, value_text, op - ) VALUES - (NEW.id, 'username', NEW.username, true), - (NEW.id, 'display_name', NEW.display_name, true), - (NEW.id, 'status', NEW.status, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_member_new_picture_uuid" - AFTER INSERT ON "%s_members" - WHEN NEW.picture_uuid IS NOT NULL - BEGIN - INSERT INTO "%s_member_changes" ( - member_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_member_update_username" - AFTER UPDATE ON "%s_members" - WHEN OLD.username != NEW.username - BEGIN - INSERT INTO "%s_member_changes" ( - member_id, attribute, value_text, op - ) VALUES - (NEW.id, 'username', OLD.username, false), - (NEW.id, 'username', NEW.username, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_member_update_display_name" - AFTER UPDATE ON "%s_members" - WHEN OLD.display_name != NEW.display_name - BEGIN - INSERT INTO "%s_member_changes" ( - member_id, attribute, value_text, op - ) VALUES - (NEW.id, 'display_name', OLD.display_name, false), - (NEW.id, 'display_name', NEW.display_name, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_member_update_status" - AFTER UPDATE ON "%s_members" - WHEN OLD.status != NEW.status - BEGIN - INSERT INTO "%s_member_changes" ( - member_id, attribute, value_text, op - ) VALUES - (NEW.id, 'status', OLD.status, false), - (NEW.id, 'status', NEW.status, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_member_add_picture_uuid" - AFTER UPDATE ON "%s_members" - WHEN ( - OLD.picture_uuid IS NULL AND - NEW.picture_uuid IS NOT NULL - ) - BEGIN - INSERT INTO "%s_member_changes" ( - member_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_member_remove_picture_uuid" - AFTER UPDATE ON "%s_members" - WHEN ( - OLD.picture_uuid IS NOT NULL AND - NEW.picture_uuid IS NULL - ) - BEGIN - INSERT INTO "%s_member_changes" ( - member_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', OLD.picture_uuid, false) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_member_update_picture_uuid" - AFTER UPDATE ON "%s_members" - WHEN ( - OLD.picture_uuid IS NOT NULL AND - NEW.picture_uuid IS NOT NULL AND - OLD.picture_uuid != NEW.picture_uuid - ) - BEGIN - INSERT INTO "%s_member_changes" ( - member_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', OLD.picture_uuid, false), - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - - CREATE TABLE IF NOT EXISTS "%s_member_roles" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - member_id INTEGER NOT NULL - REFERENCES "%s_members"(id), - role TEXT NOT NULL, - UNIQUE (member_id, role) - ) STRICT; - CREATE TABLE IF NOT EXISTS "%s_member_role_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - role_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'role', - 'logon_id' -- FIXME - ) - ), - value TEXT NOT NULL, - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - CREATE TRIGGER IF NOT EXISTS "%s_member_role_add" - AFTER INSERT ON "%s_member_roles" - BEGIN - INSERT INTO "%s_member_role_changes" ( - role_id, attribute, value, op - ) VALUES - (NEW.id, 'role', NEW.role, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_member_role_remove" - AFTER DELETE ON "%s_member_roles" - BEGIN - INSERT INTO "%s_member_role_changes" ( - role_id, attribute, value, op - ) VALUES - (OLD.id, 'role', OLD.role, false) - ; - END; - - CREATE TABLE IF NOT EXISTS "%s_channels" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - uuid BLOB NOT NULL UNIQUE, - network_id INTEGER NOT NULL - REFERENCES "%s_networks"(id), - public_name TEXT, - label TEXT NOT NULL, - description TEXT NOT NULL, - virtual INT NOT NULL CHECK(virtual IN (0, 1)), - UNIQUE (network_id, public_name) - ) STRICT; - CREATE TABLE IF NOT EXISTS "%s_channel_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - channel_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'public_name', - 'label', - 'description', - 'virtual', - 'logon_id' -- FIXME - ) - ), - value_text TEXT, - value_bool INT CHECK(value_bool IN (0, 1)), - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - CREATE TRIGGER IF NOT EXISTS "%s_channel_new" - AFTER INSERT ON "%s_channels" - BEGIN - INSERT INTO "%s_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (NEW.id, 'label', NEW.label, true), - (NEW.id, 'description', NEW.description, true) - ; - INSERT INTO "%s_channel_changes" ( - channel_id, attribute, value_bool, op - ) VALUES - (NEW.id, 'virtual', NEW.virtual, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_channel_new_public_name" - AFTER INSERT ON "%s_channels" - WHEN NEW.public_name IS NOT NULL - BEGIN - INSERT INTO "%s_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (NEW.id, 'public_name', NEW.public_name, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_channel_update_label" - AFTER UPDATE ON "%s_channels" - WHEN OLD.label != NEW.label - BEGIN - INSERT INTO "%s_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (NEW.id, 'label', OLD.label, false), - (NEW.id, 'label', NEW.label, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_channel_update_description" - AFTER UPDATE ON "%s_channels" - WHEN OLD.description != NEW.description - BEGIN - INSERT INTO "%s_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (NEW.id, 'description', OLD.description, false), - (NEW.id, 'description', NEW.description, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_channel_update_virtual" - AFTER UPDATE ON "%s_channels" - WHEN OLD.virtual != NEW.virtual - BEGIN - INSERT INTO "%s_channel_changes" ( - channel_id, attribute, value_bool, op - ) VALUES - (NEW.id, 'virtual', OLD.virtual, false), - (NEW.id, 'virtual', NEW.virtual, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_channel_add_public_name" - AFTER UPDATE ON "%s_channels" - WHEN ( - OLD.public_name IS NULL AND - NEW.public_name IS NOT NULL - ) - BEGIN - INSERT INTO "%s_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (NEW.id, 'public_name', NEW.public_name, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "%s_channel_remove_public_name" - AFTER UPDATE ON "%s_channels" - WHEN ( - OLD.public_name IS NOT NULL AND - NEW.public_name IS NULL - ) - BEGIN - INSERT INTO "%s_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (OLD.id, 'public_name', OLD.public_name, false) - ; - END; - - CREATE TABLE IF NOT EXISTS "%s_participants" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL - REFERENCES "%s_channels"(id), - member_id INTEGER NOT NULL - REFERENCES "%s_members"(id), - UNIQUE (channel_id, member_id) - ) STRICT; - CREATE TABLE IF NOT EXISTS "%s_participant_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - participant_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'connection_id' - ) - ), - value TEXT NOT NULL, - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - - CREATE TABLE IF NOT EXISTS "%s_channel_events" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - %s - ), - uuid BLOB NOT NULL UNIQUE, - channel_id INTEGER NOT NULL - REFERENCES "%s_channels"(id), - source_uuid BLOB NOT NULL, - source_type TEXT NOT NULL CHECK( - source_type IN ( - 'logon' - ) - ), - source_metadata TEXT, - type TEXT NOT NULL CHECK( - type IN ( - 'user-join', - 'user-message' - ) - ), - payload TEXT NOT NULL, - metadata TEXT - ) STRICT; - - ` - return queryT{ - write: fmt.Sprintf( - tmpl_write, - prefix, - g.SQLiteNow, - prefix, - g.SQLiteNow, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - g.SQLiteNow, - prefix, - prefix, - g.SQLiteNow, - prefix, - g.SQLiteNow, - prefix, - prefix, - prefix, - g.SQLiteNow, - prefix, - prefix, - prefix, - g.SQLiteNow, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - g.SQLiteNow, - prefix, - prefix, - g.SQLiteNow, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - g.SQLiteNow, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - g.SQLiteNow, - prefix, - prefix, - g.SQLiteNow, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - g.SQLiteNow, - prefix, - g.SQLiteNow, - prefix, - ), - } -} - -func createTables(db *sql.DB, prefix string) error { - q := createTablesSQL(prefix) - - return inTx(db, func(tx *sql.Tx) error { - _, err := tx.Exec(q.write) - return err - }) -} - -func memberRolesSQL(prefix string) queryT { - const tmpl_read = ` - SELECT role FROM "%s_member_roles" - JOIN "%s_members" ON - "%s_member_roles".member_id = "%s_members".id - WHERE "%s_members".uuid = ? - ORDER BY "%s_member_roles".id; - ` - return queryT{ - read: fmt.Sprintf( - tmpl_read, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - ), - } -} - -func collectRoles(rows *sql.Rows) ([]string, error) { - roles := []string{} - - for rows.Next() { - var role string - err := rows.Scan(&role) - if err != nil { - rows.Close() - return nil, err - } - - roles = append(roles, role) - } - - return roles, g.WrapErrors(rows.Err(), rows.Close()) -} - -func createUserSQL(prefix string) queryT { - const tmpl_write = ` - INSERT INTO "%s_users" ( - user_uuid, username, display_name, picture_uuid, deleted - ) VALUES ( - ?, ?, ?, NULL, false - ) RETURNING id, timestamp; - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix), - } -} - -func createUserStmt( - cfg dbconfigT, -) (func(newUserT) (userT, error), func() error, error) { - q := createUserSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(newUser newUserT) (userT, error) { - user := userT{ - uuid: newUser.uuid, - username: newUser.username, - displayName: newUser.displayName, - } - - var timestr string - err := writeStmt.QueryRow( - newUser.uuid[:], - newUser.username, - newUser.displayName, - ).Scan(&user.id, ×tr) - if err != nil { - return userT{}, err - } - - user.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return userT{}, err - } - - return user, nil - } - - return fn, writeStmt.Close, nil -} - -func userByUUIDSQL(prefix string) queryT { - const tmpl_read = ` - SELECT - id, - timestamp, - username, - display_name, - picture_uuid - FROM "%s_users" - WHERE - user_uuid = ? AND - deleted = false; - ` - return queryT{ - read: fmt.Sprintf(tmpl_read, prefix), - } -} - -func userByUUIDStmt( - cfg dbconfigT, -) (func(uuid.UUID) (userT, error), func() error, error) { - q := userByUUIDSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - fn := func(userID uuid.UUID) (userT, error) { - user := userT{ - uuid: userID, - } - - var ( - timestr string - picture_id_bytes []byte - ) - err := readStmt.QueryRow(userID[:]).Scan( - &user.id, - ×tr, - &user.username, - &user.displayName, - &picture_id_bytes, - ) - if err != nil { - return userT{}, err - } - if picture_id_bytes != nil { - pictureID := uuid.UUID(picture_id_bytes) - user.pictureID = &pictureID - } - - user.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return userT{}, err - } - - return user, err - } - - return fn, readStmt.Close, nil -} - -func updateUserSQL(prefix string) queryT { - const tmpl_write = ` - UPDATE "%s_users" - SET - username = ?, - display_name = ?, - picture_uuid = ? - WHERE - id = ? AND - deleted = false - RETURNING id; - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix), - } -} - -func updateUserStmt( - cfg dbconfigT, -) (func(userT) error, func() error, error) { - q := updateUserSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(user userT) error { - var picture_id_bytes []byte = nil - if user.pictureID != nil { - picture_id_bytes = user.pictureID[:] - } - var _id int64 - return writeStmt.QueryRow( - user.username, - user.displayName, - picture_id_bytes, - user.id, - ).Scan(&_id) - } - - return fn, writeStmt.Close, nil -} - -func deleteUserSQL(prefix string) queryT { - const tmpl_write = ` - UPDATE "%s_users" - SET deleted = true - WHERE - user_uuid = ? AND - deleted = false - RETURNING id; - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix), - } -} - -func deleteUserStmt( - cfg dbconfigT, -) (func(uuid.UUID) error, func() error, error) { - q := deleteUserSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(userID uuid.UUID) error { - var _id int64 - return writeStmt.QueryRow(userID[:]).Scan(&_id) - } - - return fn, writeStmt.Close, nil -} - -func addNetworkSQL(prefix string) queryT { - const tmpl_write = ` - INSERT INTO "%s_networks" ( - uuid, name, description, type, deleted - ) - VALUES ( - ?, - ?, - ?, - ?, - false - ) RETURNING id; - - WITH creator AS ( - SELECT username, display_name, picture_uuid - FROM "%s_users" - WHERE id = ? AND deleted = false - ), new_network AS ( - SELECT id FROM "%s_networks" WHERE uuid = ? - ) - INSERT INTO "%s_members" ( - uuid, network_id, user_id, username, display_name, - picture_uuid, status, active_uniq - ) VALUES ( - ?, - (SELECT id FROM new_network), - ?, - (SELECT username FROM creator), - (SELECT display_name FROM creator), - (SELECT picture_uuid FROM creator), - 'active', - 'active' - ) RETURNING id; - - WITH new_member AS ( - SELECT id FROM "%s_members" WHERE uuid = ? - ) - INSERT INTO "%s_member_roles" (member_id, role) - VALUES ( - (SELECT id FROM new_member), - 'admin' - ), - ( - (SELECT id FROM new_member), - 'creator' - ) - RETURNING id; - ` - const tmpl_read = ` - SELECT id, timestamp FROM "%s_networks" - WHERE uuid = ? AND deleted = false; - ` - return queryT{ - write: fmt.Sprintf( - tmpl_write, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - ), - read: fmt.Sprintf(tmpl_read, prefix), - } -} - -func addNetworkStmt( - cfg dbconfigT, -) ( - func(userT, newNetworkT, uuid.UUID) (networkT, error), - func() error, - error, -) { - q := addNetworkSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - privateDB, err := sql.Open(golite.DriverName, cfg.dbpath) - if err != nil { - readStmt.Close() - return nil, nil, err - } - - writeFn, writeFnClose := execSerialized(q.write, privateDB) - - fn := func( - user userT, - newNetwork newNetworkT, - memberID uuid.UUID, - ) (networkT, error) { - network := networkT{ - uuid: newNetwork.uuid, - name: newNetwork.name, - description: newNetwork.description, - type_: newNetwork.type_, - } - - err := writeFn( - newNetwork.uuid[:], - newNetwork.name, - newNetwork.description, - newNetwork.type_, - user.id, - newNetwork.uuid[:], - memberID[:], - user.id, - memberID[:], - ) - if err != nil { - return networkT{}, err - } - - var timestr string - err = readStmt.QueryRow(network.uuid[:]).Scan( - &network.id, - ×tr, - ) - if err != nil { - return networkT{}, err - } - - network.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return networkT{}, err - } - - return network, nil - } - - closeFn := func() error { - writeFnClose() - return g.SomeError(privateDB.Close(), readStmt.Close()) - } - - return fn, closeFn, nil -} - -func getNetworkSQL(prefix string) queryT { - const tmpl_read = ` - WITH probing_user AS ( - SELECT id FROM "%s_users" - WHERE id = ? AND deleted = false - ), target_network AS ( - SELECT id FROM "%s_networks" - WHERE uuid = ? AND deleted = false - ) - SELECT - id, - timestamp, - name, - description, - type - FROM "%s_networks" - WHERE - uuid = ? AND - deleted = false AND - ? IN probing_user AND - ( - type IN ('public', 'unlisted') OR - ? IN ( - SELECT user_id FROM "%s_members" - WHERE - user_id = ? AND - network_id IN target_network AND - status != 'removed' - ) - ); - ` - return queryT{ - read: fmt.Sprintf( - tmpl_read, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - ), - } -} - -func getNetworkStmt( - cfg dbconfigT, -) (func(userT, uuid.UUID) (networkT, error), func() error, error) { - q := getNetworkSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - fn := func(user userT, networkID uuid.UUID) (networkT, error) { - network := networkT{ - uuid: networkID, - } - - var timestr string - err := readStmt.QueryRow( - user.id, - networkID[:], - networkID[:], - user.id, - user.id, - user.id, - ).Scan( - &network.id, - ×tr, - &network.name, - &network.description, - &network.type_, - ) - if err != nil { - return networkT{}, err - } - - network.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return networkT{}, err - } - - return network, nil - } - - return fn, readStmt.Close, nil -} - -func networkEach(rows *sql.Rows, callback func(networkT) error) error { - if rows == nil { - return nil - } - - for rows.Next() { - var ( - network networkT - timestr string - network_id_bytes []byte - deleted bool - ) - err := rows.Scan( - &network.id, - ×tr, - &network_id_bytes, - &network.name, - &network.description, - &network.type_, - &deleted, - ) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - network.uuid = uuid.UUID(network_id_bytes) - - if deleted { - return sql.ErrNoRows - } - - network.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - - err = callback(network) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - } - - return g.WrapErrors(rows.Err(), rows.Close()) -} - -func networksSQL(prefix string) queryT { - const tmpl_read = ` - WITH current_user AS ( - SELECT id, deleted FROM "%s_users" WHERE id = ? - ) - SELECT - "%s_networks".id, - "%s_networks".timestamp, - "%s_networks".uuid, - "%s_networks".name, - "%s_networks".description, - "%s_networks".type, - (SELECT deleted FROM current_user) - FROM "%s_networks" - JOIN "%s_members" ON - "%s_networks".id = "%s_members".network_id - WHERE ( - "%s_networks".type = 'public' OR - "%s_networks".id IN ( - SELECT network_id FROM "%s_members" - WHERE user_id IN (SELECT id FROM current_user) - ) - ) AND "%s_networks".deleted = false - ORDER BY "%s_networks".id; - ` - return queryT{ - read: fmt.Sprintf( - tmpl_read, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - ), - } -} - -func networksStmt( - cfg dbconfigT, -) (func(userT) (*sql.Rows, error), func() error, error) { - q := networksSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - fn := func(user userT) (*sql.Rows, error) { - return readStmt.Query(user.id) - } - - return fn, readStmt.Close, nil -} - -func setNetworkSQL(prefix string) queryT { - const tmpl_write = ` - UPDATE "%s_networks" - SET - name = ?, - description = ?, - type = ? - WHERE id = ? AND deleted = false - RETURNING ( - SELECT CASE WHEN EXISTS ( - SELECT role from "%s_member_roles" - WHERE - member_id = ? AND - role IN ( - 'admin', - 'network-settings-update' - ) AND ? IN ( - SELECT network_id - FROM "%s_members" - WHERE - id = ? AND - status = 'active' - ) - ) THEN true ELSE RAISE( - ABORT, - 'member not allowed to update network data' - ) END - ); - - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix, prefix, prefix), - } -} - -func setNetworkStmt( - cfg dbconfigT, -) (func(memberT, networkT) error, func() error, error) { - q := setNetworkSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT, network networkT) error { - var _allowed bool - return writeStmt.QueryRow( - network.name, - network.description, - network.type_, - network.id, - actor.id, - network.id, - actor.id, - ).Scan(&_allowed) - } - - return fn, writeStmt.Close, nil -} - -func nipNetworkSQL(prefix string) queryT { - const tmpl_write = ` - WITH target_network AS ( - SELECT network_id AS id - FROM "%s_members" - WHERE - id = ? AND - status = 'active' - ) - UPDATE "%s_networks" - SET deleted = true - WHERE id IN target_network AND deleted = false - RETURNING ( - SELECT CASE WHEN EXISTS ( - SELECT role FROM "%s_member_roles" - WHERE - member_id = ? AND - role IN ( - 'admin' - ) - ) THEN true ELSE RAISE( - ABORT, - 'member not allowed to delete network' - ) END - ); - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix, prefix, prefix), - } -} - -func nipNetworkStmt( - cfg dbconfigT, -) (func(memberT) error, func() error, error) { - q := nipNetworkSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT) error { - var _allowed bool - return writeStmt.QueryRow(actor.id, actor.id).Scan(&_allowed) - } - - return fn, writeStmt.Close, nil -} - -func membershipSQL(prefix string) queryT { - const tmpl_read = ` - SELECT - "%s_members".id, - "%s_members".timestamp, - "%s_members".uuid, - "%s_members".username, - "%s_members".display_name, - "%s_members".picture_uuid, - "%s_members".status - FROM "%s_members" - JOIN "%s_users" ON - "%s_users".id = "%s_members".user_id - JOIN "%s_networks" ON - "%s_networks".id = "%s_members".network_id - WHERE - "%s_members".user_id = ? AND - "%s_members".network_id = ? AND - "%s_members".status = 'active' AND - "%s_users".deleted = false AND - "%s_networks".deleted = false; - ` - return queryT{ - read: fmt.Sprintf( - tmpl_read, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - ), - } -} - -func membershipStmt( - cfg dbconfigT, -) (func(userT, networkT) (memberT, error), func() error, error) { - q := membershipSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - rolesStmt, err := cfg.shared.Prepare(memberRolesSQL(cfg.prefix).read) - if err != nil { - readStmt.Close() - return nil, nil, err - } - - fn := func(actor userT, network networkT) (memberT, error) { - member := memberT{} - - var ( - timestr string - member_id_bytes []byte - picture_id_bytes []byte - ) - err := readStmt.QueryRow(actor.id, network.id).Scan( - &member.id, - ×tr, - &member_id_bytes, - &member.username, - &member.displayName, - &picture_id_bytes, - &member.status, - ) - if err != nil { - return memberT{}, err - } - member.uuid = uuid.UUID(member_id_bytes) - - member.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return memberT{}, err - } - - rows, err := rolesStmt.Query(member_id_bytes) - if err != nil { - return memberT{}, err - } - - member.roles, err = collectRoles(rows) - if err != nil { - return memberT{}, err - } - - return member, nil - } - - closeFn := func() error { - return g.SomeError( - readStmt.Close(), - rolesStmt.Close(), - ) - } - - return fn, closeFn, nil -} - -func addMemberSQL(prefix string) queryT { - const tmpl_write = ` - WITH target_user AS ( - SELECT id, username, display_name, picture_uuid - FROM "%s_users" - WHERE user_uuid = ? AND deleted = false - ), target_network AS ( - SELECT "%s_members".network_id AS id - FROM "%s_members" - JOIN "%s_networks" ON - "%s_members".network_id = "%s_networks".id - WHERE - "%s_members".id = ? AND - "%s_members".status = 'active' AND - "%s_networks".deleted = false - ) - INSERT INTO "%s_members" ( - uuid, network_id, user_id, username, display_name, - picture_uuid, status, active_uniq - ) VALUES ( - ?, - (SELECT id FROM target_network), - (SELECT id FROM target_user), - ?, - (SELECT display_name FROM target_user), - (SELECT picture_uuid FROM target_user), - 'active', - 'active' - ) RETURNING id, timestamp, display_name, picture_uuid, status, ( - SELECT CASE WHEN EXISTS ( - SELECT role from "%s_member_roles" - WHERE - member_id = ? AND - role IN ( - 'admin', - 'add-member' - ) - ) THEN true ELSE RAISE( - ABORT, - 'member not allowed to add another member' - ) END - ); - ` - return queryT{ - write: fmt.Sprintf( - tmpl_write, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - ), - } -} - -func addMemberStmt( - cfg dbconfigT, -) (func(memberT, newMemberT) (memberT, error), func() error, error) { - q := addMemberSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - rolesStmt, err := cfg.shared.Prepare(memberRolesSQL(cfg.prefix).read) - if err != nil { - writeStmt.Close() - return nil, nil, err - } - - fn := func(actor memberT, newMember newMemberT) (memberT, error) { - member := memberT{ - uuid: newMember.memberID, - username: newMember.username, - } - - var ( - timestr string - picture_id_bytes []byte - _allowed bool - ) - err := writeStmt.QueryRow( - newMember.userID[:], - actor.id, - newMember.memberID[:], - newMember.username, - actor.id, - ).Scan( - &member.id, - ×tr, - &member.displayName, - &picture_id_bytes, - &member.status, - &_allowed, - ) - if err != nil { - return memberT{}, err - } - if picture_id_bytes != nil { - pictureID := uuid.UUID(picture_id_bytes) - member.pictureID = &pictureID - } - - member.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return memberT{}, err - } - - rows, err := rolesStmt.Query(member.uuid[:]) - if err != nil { - return memberT{}, err - } - - member.roles, err = collectRoles(rows) - if err != nil { - return memberT{}, err - } - - return member, nil - } - - closeFn := func() error { - return g.SomeError( - writeStmt.Close(), - rolesStmt.Close(), - ) - } - - return fn, closeFn, nil -} - -func addRoleSQL(prefix string) queryT { - const tmpl_write = ` - INSERT INTO "%s_member_roles" (member_id, role) - VALUES (?, ?); - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix), - } -} - -func addRoleStmt( - cfg dbconfigT, -) (func(memberT, string, memberT) error, func() error, error) { - q := addRoleSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT, role string, member memberT) error { - // FIXME: do authorization - _, err := writeStmt.Exec(member.id, role) - return err - } - - return fn, writeStmt.Close, nil -} - -func dropRoleSQL(prefix string) queryT { - const tmpl_write = ` - DELETE FROM "%s_member_roles" - WHERE - member_id = ? AND - role = ? - RETURNING 1; - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix), - } -} - -func dropRoleStmt( - cfg dbconfigT, -) (func(memberT, string, memberT) error, func() error, error) { - q := dropRoleSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT, role string, member memberT) error { - // FIXME: do authorization - // _, err := writeStmt.Exec(member.id, role) - // return err - var _id int64 - return writeStmt.QueryRow(member.id, role).Scan(&_id) - } - - return fn, writeStmt.Close, nil -} - - -func showMemberSQL(prefix string) queryT { - const tmpl_read = ` - WITH current_network AS ( - SELECT network_id - FROM "%s_members" - WHERE id = ? - ) - SELECT - id, - timestamp, - username, - display_name, - picture_uuid, - status - FROM "%s_members" - WHERE - uuid = ? AND - network_id IN current_network; - ` - return queryT{ - read: fmt.Sprintf(tmpl_read, prefix, prefix), - } -} - -func showMemberStmt( - cfg dbconfigT, -) (func(memberT, uuid.UUID) (memberT, error), func() error, error) { - q := showMemberSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - rolesStmt, err := cfg.shared.Prepare(memberRolesSQL(cfg.prefix).read) - if err != nil { - readStmt.Close() - return nil, nil, err - } - - fn := func(actor memberT, memberID uuid.UUID) (memberT, error) { - member := memberT{ - uuid: memberID, - } - - var ( - timestr string - picture_id_bytes []byte - ) - err := readStmt.QueryRow(actor.id, memberID[:]).Scan( - &member.id, - ×tr, - &member.username, - &member.displayName, - &picture_id_bytes, - &member.status, - ) - if err != nil { - return memberT{}, err - } - if picture_id_bytes != nil { - pictureID := uuid.UUID(picture_id_bytes) - // FIXME: test this - member.pictureID = &pictureID - } - - member.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return memberT{}, err - } - - rows, err := rolesStmt.Query(memberID[:]) - if err != nil { - return memberT{}, err - } - - member.roles, err = collectRoles(rows) - if err != nil { - return memberT{}, err - } - - return member, nil - } - - closeFn := func() error { - return g.SomeError( - readStmt.Close(), - rolesStmt.Close(), - ) - } - - return fn, closeFn, nil -} - -func memberEach(rows *sql.Rows, callback func(memberT) error) error { - if rows == nil { - return nil - } - - for rows.Next() { - var ( - member memberT - timestr string - member_id_bytes []byte - picture_id_bytes []byte - ) - err := rows.Scan( - &member.id, - ×tr, - &member_id_bytes, - &member.username, - &member.displayName, - &picture_id_bytes, - &member.status, - ) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - member.uuid = uuid.UUID(member_id_bytes) - if picture_id_bytes != nil { - pictureID := uuid.UUID(picture_id_bytes) - member.pictureID = &pictureID - } - - member.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - - err = callback(member) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - } - - return g.WrapErrors(rows.Err(), rows.Close()) -} - -func membersSQL(prefix string) queryT { - const tmpl_read = ` - WITH target_network AS ( - SELECT "%s_members".network_id - FROM "%s_members" - JOIN "%s_networks" ON - "%s_members".network_id = "%s_networks".id - WHERE - "%s_members".id = ? AND - "%s_networks".deleted = false - ) - SELECT - id, - timestamp, - uuid, - username, - display_name, - picture_uuid, - status - FROM "%s_members" - WHERE - network_id IN target_network AND - status = 'active'; - ` - return queryT{ - read: fmt.Sprintf( - tmpl_read, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - ), - } -} - -func membersStmt( - cfg dbconfigT, -) (func(memberT) (*sql.Rows, error), func() error, error) { - q := membersSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT) (*sql.Rows, error) { - return readStmt.Query(actor.id) - } - - return fn, readStmt.Close, nil -} - -func editMemberSQL(prefix string) queryT { - const tmpl_write = ` - UPDATE "%s_members" - SET - status = ? - WHERE id = ? - RETURNING id; - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix), - } -} - -func editMemberStmt( - cfg dbconfigT, -) (func(memberT, memberT) error, func() error, error) { - q := editMemberSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT, member memberT) error { - var _id int64 - return writeStmt.QueryRow( - member.status, - member.id, - ).Scan(&_id) - } - - return fn, writeStmt.Close, nil -} - -func dropMemberSQL(prefix string) queryT { - const tmpl_write = ` - UPDATE "%s_members" SET status = 'removed' - WHERE uuid = ? RETURNING id; - - DELETE FROM "%s_member_roles" - WHERE - role != 'creator' AND - member_id IN ( - SELECT id FROM "%s_members" - WHERE uuid = ? - ) - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix, prefix, prefix), - } -} - -func dropMemberStmt( - cfg dbconfigT, -) (func(memberT, uuid.UUID) error, func() error, error) { - q := dropMemberSQL(cfg.prefix) - - privateDB, err := sql.Open(golite.DriverName, cfg.dbpath) - if err != nil { - return nil, nil, err - } - - writeFn, writeFnClose := execSerialized(q.write, privateDB) - - fn := func(actor memberT, memberID uuid.UUID) error { - err := writeFn(memberID[:], memberID[:]) - if err != nil { - return err - } - - // if res == 0 { // FIXME } - return nil - } - - closeFn := func() error { - writeFnClose() - return privateDB.Close() - } - - return fn, closeFn, nil -} - -func addChannelSQL(prefix string) queryT { - const tmpl_write = ` - WITH target_network AS ( - SELECT network_id AS id - FROM "%s_members" - WHERE id = ? - ) - INSERT INTO "%s_channels" ( - uuid, - network_id, - public_name, - label, - description, - virtual - ) VALUES ( - ?, - (SELECT id FROM target_network), - ?, - ?, - ?, - ? - ) RETURNING id, timestamp; - - WITH new_channel AS ( - SELECT id FROM "%s_channels" WHERE uuid = ? - ) - INSERT INTO "%s_participants" (channel_id, member_id) - VALUES ( - (SELECT id FROM new_channel), - ? - ); - ` - const tmpl_read = ` - SELECT id, timestamp FROM "%s_channels" - WHERE uuid = ?; - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix, prefix, prefix, prefix), - read: fmt.Sprintf(tmpl_read, prefix), - } -} - -func addChannelStmt( - cfg dbconfigT, -) (func (memberT, newChannelT) (channelT, error), func() error, error) { - q := addChannelSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - privateDB, err := sql.Open(golite.DriverName, cfg.dbpath) - if err != nil { - readStmt.Close() - return nil, nil, err - } - - writeFn, writeFnClose := execSerialized(q.write, privateDB) - - fn := func(actor memberT, newChannel newChannelT) (channelT, error) { - channel := channelT{ - uuid: newChannel.uuid, - publicName: newChannel.publicName, - label: newChannel.label, - description: newChannel.description, - virtual: newChannel.virtual, - } - - var timestr string - err := writeFn( - actor.id, - newChannel.uuid[:], - newChannel.publicName, - newChannel.label, - newChannel.description, - newChannel.virtual, - newChannel.uuid[:], - actor.id, - ) - if err != nil { - return channelT{}, err - } - - err = readStmt.QueryRow(newChannel.uuid[:]).Scan( - &channel.id, - ×tr, - ) - if err != nil { - return channelT{}, err - } - - channel.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return channelT{}, err - } - - return channel, nil - } - - closeFn := func() error { - writeFnClose() - return readStmt.Close() - } - - return fn, closeFn, nil -} - -/* -func chanByName(prefix string) queryT { - const tmpl_read = ` - SELECT - id, - timestamp, - uuid, - public, - - ` - return queryT{ - read: fmt.Sprintf(tmpl_read, prefix), - } -} - -func chanByStmt( - cfg dbconfigT, -) ( -FIXME -*/ - -func channelEach(rows *sql.Rows, callback func(channelT) error) error { - if rows == nil { - return nil - } - - for rows.Next() { - var ( - channel channelT - timestr string - channel_id_bytes []byte - publicName sql.NullString - ) - err := rows.Scan( - &channel.id, - ×tr, - &channel_id_bytes, - &publicName, - &channel.label, - &channel.description, - &channel.virtual, - ) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - channel.uuid = uuid.UUID(channel_id_bytes) - if publicName.Valid { - channel.publicName = &publicName.String - } - - channel.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - - err = callback(channel) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - } - - return g.WrapErrors(rows.Err(), rows.Close()) -} - -func channelsSQL(prefix string) queryT { - const tmpl_read = ` - WITH current_network AS ( - SELECT network_id AS id - FROM "%s_members" - WHERE id = ? - ), member_private_channels AS ( - SELECT channel_id AS id - FROM "%s_participants" - WHERE member_id = ? - ) - SELECT - id, - timestamp, - uuid, - public_name, - label, - description, - virtual - FROM "%s_channels" - WHERE - network_id IN current_network AND - ( - public_name IS NOT NULL OR - id IN member_private_channels - ) - ORDER BY id; - ` - return queryT{ - read: fmt.Sprintf(tmpl_read, prefix, prefix, prefix), - } -} - -func channelsStmt( - cfg dbconfigT, -) (func(memberT) (*sql.Rows, error), func() error, error) { - q := channelsSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT) (*sql.Rows, error) { - return readStmt.Query(actor.id, actor.id) - } - - return fn, readStmt.Close, nil -} - -func setChannelSQL(prefix string) queryT { - const tmpl_write = ` - WITH participant_channel AS ( - SELECT channel_id AS id - FROM "%s_participants" - WHERE - member_id = ? AND - channel_id = ? - ) - UPDATE "%s_channels" - SET - description = ?, - public_name = ? - WHERE id IN participant_channel - RETURNING id; - ` - const tmpl_read = ` - SELECT ( - SELECT network_id AS id - FROM "%s_channels" - WHERE id = ? - ) AS channel_network_id, ( - SELECT network_id AS id - FROM "%s_members" - WHERE id = ? - ) AS member_network_id; - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix, prefix), - read: fmt.Sprintf(tmpl_read, prefix, prefix), - } -} - -func setChannelStmt( - cfg dbconfigT, -) (func(memberT, channelT) error, func() error, error) { - q := setChannelSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - readStmt.Close() - return nil, nil, err - } - - fn := func(actor memberT, channel channelT) error { - var ( - netid1 sql.NullInt64 - netid2 sql.NullInt64 - ) - err := readStmt.QueryRow(channel.id, actor.id).Scan( - &netid1, - &netid2, - ) - if err != nil { - return err - } - if !netid1.Valid || !netid2.Valid || - netid1.Int64 != netid2.Int64 { - return sql.ErrNoRows - } - - var _id int64 - return writeStmt.QueryRow( - actor.id, - channel.id, - channel.description, - channel.publicName, - ).Scan(&_id) - } - - closeFn := func() error { - return g.SomeError( - readStmt.Close(), - writeStmt.Close(), - ) - } - - return fn, closeFn, nil -} - -func endChannelSQL(prefix string) queryT { - const tmpl_write = ` - -- FIXME %s - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix), - } -} - -func endChannelStmt( - cfg dbconfigT, -) (func(memberT, uuid.UUID) error, func() error, error) { - q := endChannelSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT, channelID uuid.UUID) error { - _, err := writeStmt.Exec(channelID[:]) - return err - } - - return fn, writeStmt.Close, nil -} - -func joinSQL(prefix string) queryT { - const tmpl_write = ` - WITH target_channel AS ( - SELECT id - FROM "%s_channels" - WHERE - uuid = ? AND - public_name IS NOT NULL - ) - INSERT INTO "%s_participants" (channel_id, member_id) - VALUES ( - (SELECT id FROM target_channel), - ? - ) RETURNING id; - ` - const tmpl_read = ` - SELECT ( - SELECT network_id AS id - FROM "%s_channels" - WHERE - uuid = ? AND - public_name IS NOT NULL - ) AS channel_network_id, ( - SELECT network_id AS id - FROM "%s_members" WHERE id = ? - ) AS member_network_id; - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix, prefix), - read: fmt.Sprintf(tmpl_read, prefix, prefix), - } -} - -func joinStmt( - cfg dbconfigT, -) (func(memberT, uuid.UUID) error, func() error, error) { - q := joinSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - readStmt.Close() - return nil, nil, err - } - - fn := func(actor memberT, channelID uuid.UUID) error { - var ( - netid1 sql.NullInt64 - netid2 sql.NullInt64 - ) - err := readStmt.QueryRow(channelID[:], actor.id).Scan( - &netid1, - &netid2, - ) - if err != nil { - return err - } - - if !netid1.Valid || !netid2.Valid || - netid1.Int64 != netid2.Int64 { - return sql.ErrNoRows - } - - var _id int64 - return writeStmt.QueryRow(channelID[:], actor.id).Scan(&_id) - } - - closeFn := func() error { - return g.SomeError( - readStmt.Close(), - writeStmt.Close(), - ) - } - - return fn, closeFn, nil -} - -func partSQL(prefix string) queryT { - const tmpl_write = ` - WITH target_channel AS ( - SELECT id - FROM "%s_channels" - WHERE - id = ? AND - virtual = false - ) - DELETE FROM "%s_participants" - WHERE - member_id = ? AND - channel_id IN target_channel - RETURNING 1; - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix, prefix), - } -} - -func partStmt( - cfg dbconfigT, -) (func(memberT, channelT) error, func() error, error) { - q := partSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT, channel channelT) error { - var _id int64 - return writeStmt.QueryRow(channel.id, actor.id).Scan(&_id) - } - - return fn, writeStmt.Close, nil -} - -func nameEach(rows *sql.Rows, callback func(memberT) error) error { - if rows == nil { - return nil - } - - for rows.Next() { - var ( - member memberT - timestr string - member_id_bytes []byte - ) - err := rows.Scan( - &member.id, - ×tr, - &member_id_bytes, - ) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - member.uuid = uuid.UUID(member_id_bytes) - - member.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - - err = callback(member) - if err != nil { - return g.WrapErrors(rows.Close(), err) - } - } - - return g.WrapErrors(rows.Err(), rows.Close()) -} - -func namesSQL(prefix string) queryT { - const tmpl_read = ` - -- FIXME %s - ` - return queryT{ - read: fmt.Sprintf(tmpl_read, prefix), - } -} - -func namesStmt( - cfg dbconfigT, -) (func(memberT, uuid.UUID) (*sql.Rows, error), func() error, error) { - q := namesSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT, channelID uuid.UUID) (*sql.Rows, error) { - return readStmt.Query(channelID[:]) - } - - return fn, readStmt.Close, nil -} - -func addEventSQL(prefix string) queryT { - const tmpl_write = ` - INSERT INTO "%s_channel_events" ( - uuid, channel_id, source_uuid, source_type, - source_metadata, type, payload, metadata - ) VALUES ( - ?, - (SELECT id FROM "%s_channels" WHERE uuid = ?), - ?, - ?, - ?, - ?, - ?, - ? - ) RETURNING id, timestamp; - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix, prefix), - } -} - -func addEventStmt( - cfg dbconfigT, -) (func (newEventT) (eventT, error), func() error, error) { - q := addEventSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - fn := func(newEvent newEventT) (eventT, error) { - event := eventT{ - uuid: newEvent.eventID, - channelID: newEvent.channelID, - source: newEvent.source, - type_: newEvent.type_, - payload: newEvent.payload, - metadata: newEvent.metadata, - } - - var timestr string - err := writeStmt.QueryRow( - newEvent.eventID[:], - newEvent.channelID[:], - newEvent.source.uuid[:], - newEvent.source.type_, - newEvent.source.metadata, - newEvent.type_, - newEvent.payload, - newEvent.metadata, - ).Scan(&event.id, ×tr) - if err != nil { - return eventT{}, err - } - - event.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - return eventT{}, err - } - - return event, nil - } - - return fn, writeStmt.Close, nil -} - -func eventEach(rows *sql.Rows, callback func(eventT) error) error { - if rows == nil { - return nil - } - - for rows.Next() { - var ( - event eventT - timestr string - event_id_bytes []byte - channel_id_bytes []byte - ) - err := rows.Scan( - &event.id, - ×tr, - &event_id_bytes, - &channel_id_bytes, - &event.type_, - &event.payload, - ) - if err != nil { - rows.Close() - return err - } - event.uuid = uuid.UUID(event_id_bytes) - event.channelID = uuid.UUID(channel_id_bytes) - - event.timestamp, err = time.Parse(time.RFC3339Nano, timestr) - if err != nil { - rows.Close() - return err - } - - err = callback(event) - if err != nil { - rows.Close() - return err - } - } - - return g.WrapErrors(rows.Err(), rows.Close()) -} - -func allAfterSQL(prefix string) queryT { - const tmpl_read = ` - WITH landmark_event AS ( - SELECT id, channel_id - FROM "%s_channel_events" - WHERE uuid = ? - ) - SELECT - "%s_channel_events".id, - "%s_channel_events".timestamp, - "%s_channel_events".uuid, - "%s_channels".uuid, - -- "%s_channel_events".connection_uuid, - "%s_channel_events".type, - "%s_channel_events".payload - FROM "%s_channel_events" - JOIN "%s_channels" ON - "%s_channel_events".channel_id = "%s_channels".id - WHERE - "%s_channel_events".id > ( - SELECT id FROM landmark_event - ) AND channel_id = ( - SELECT channel_id FROM landmark_event - ); - ` - return queryT{ - read: fmt.Sprintf( - tmpl_read, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - ), - } -} - -func allAfterStmt( - cfg dbconfigT, -) (func (memberT, uuid.UUID) (*sql.Rows, error), func() error, error) { - q := allAfterSQL(cfg.prefix) - - readStmt, err := cfg.shared.Prepare(q.read) - if err != nil { - return nil, nil, err - } - - fn := func(actor memberT, eventID uuid.UUID) (*sql.Rows, error) { - return readStmt.Query(eventID[:]) - } - - return fn, readStmt.Close, nil -} - -func logMessageSQL(prefix string) queryT{ - const tmpl_write = ` - -- FIXME %s - ` - return queryT{ - write: fmt.Sprintf(tmpl_write, prefix), - } -} - -func logMessageStmt( - cfg dbconfigT, -) (func(userT, messageT) error, func() error, error) { - q := logMessageSQL(cfg.prefix) - - writeStmt, err := cfg.shared.Prepare(q.write) - if err != nil { - return nil, nil, err - } - - // FIXME: actor? - fn := func(user userT, message messageT) error { - return nil // FIXME - _, err := writeStmt.Exec(user, message) - return err - } - - return fn, writeStmt.Close, nil -} - -func initDB( - dbpath string, - prefix string, -) (queriesT, error) { - err := g.ValidateSQLTablePrefix(prefix) - if err != nil { - return queriesT{}, err - } - - shared, err := sql.Open(golite.DriverName, dbpath) - if err != nil { - return queriesT{}, err - } - - cfg := dbconfigT{ - shared: shared, - dbpath: dbpath, - prefix: prefix, - } - - createTablesErr := createTables(shared, prefix) - createUser, createUserClose, createUserErr := createUserStmt(cfg) - userByUUID, userByUUIDClose, userByUUIDErr := userByUUIDStmt(cfg) - updateUser, updateUserClose, updateUserErr := updateUserStmt(cfg) - deleteUser, deleteUserClose, deleteUserErr := deleteUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - getNetwork, getNetworkClose, getNetworkErr := getNetworkStmt(cfg) - networks, networksClose, networksErr := networksStmt(cfg) - setNetwork, setNetworkClose, setNetworkErr := setNetworkStmt(cfg) - nipNetwork, nipNetworkClose, nipNetworkErr := nipNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - addRole, addRoleClose, addRoleErr := addRoleStmt(cfg) - dropRole, dropRoleClose, dropRoleErr := dropRoleStmt(cfg) - showMember, showMemberClose, showMemberErr := showMemberStmt(cfg) - members, membersClose, membersErr := membersStmt(cfg) - editMember, editMemberClose, editMemberErr := editMemberStmt(cfg) - dropMember, dropMemberClose, dropMemberErr := dropMemberStmt(cfg) - addChannel, addChannelClose, addChannelErr := addChannelStmt(cfg) - channels, channelsClose, channelsErr := channelsStmt(cfg) - setChannel, setChannelClose, setChannelErr := setChannelStmt(cfg) - endChannel, endChannelClose, endChannelErr := endChannelStmt(cfg) - join, joinClose, joinErr := joinStmt(cfg) - part, partClose, partErr := partStmt(cfg) - names, namesClose, namesErr := namesStmt(cfg) - addEvent, addEventClose, addEventErr := addEventStmt(cfg) - allAfter, allAfterClose, allAfterErr := allAfterStmt(cfg) - logMessage, logMessageClose, logMessageErr := logMessageStmt(cfg) - - closeFn := func() error { - return g.SomeFnError( - createUserClose, - userByUUIDClose, - updateUserClose, - deleteUserClose, - addNetworkClose, - getNetworkClose, - networksClose, - setNetworkClose, - nipNetworkClose, - membershipClose, - addMemberClose, - addRoleClose, - dropRoleClose, - showMemberClose, - membersClose, - editMemberClose, - dropMemberClose, - addChannelClose, - channelsClose, - setChannelClose, - endChannelClose, - joinClose, - partClose, - namesClose, - addEventClose, - allAfterClose, - logMessageClose, - ) - } - - err = g.SomeError( - createTablesErr, - createUserErr, - userByUUIDErr, - updateUserErr, - deleteUserErr, - addNetworkErr, - getNetworkErr, - networksErr, - setNetworkErr, - nipNetworkErr, - membershipErr, - addMemberErr, - addRoleErr, - dropRoleErr, - showMemberErr, - membersErr, - editMemberErr, - dropMemberErr, - addChannelErr, - channelsErr, - setChannelErr, - endChannelErr, - joinErr, - partErr, - namesErr, - addEventErr, - allAfterErr, - logMessageErr, - ) - if err != nil { - closeFn() - return queriesT{}, err - } - - var connMutex sync.RWMutex - return queriesT{ - createUser: func(a newUserT) (userT, error) { - connMutex.RLock() - defer connMutex.RUnlock() - return createUser(a) - }, - userByUUID: func(a uuid.UUID) (userT, error) { - connMutex.RLock() - defer connMutex.RUnlock() - return userByUUID(a) - }, - updateUser: func(a userT) error { - connMutex.RLock() - defer connMutex.RUnlock() - return updateUser(a) - }, - deleteUser: func(a uuid.UUID) error { - connMutex.RLock() - defer connMutex.RUnlock() - return deleteUser(a) - }, - addNetwork: func( - a userT, - b newNetworkT, - c uuid.UUID, - ) (networkT, error) { - connMutex.RLock() - defer connMutex.RUnlock() - return addNetwork(a, b, c) - }, - getNetwork: func(a userT, b uuid.UUID) (networkT, error) { - connMutex.RLock() - defer connMutex.RUnlock() - return getNetwork(a, b) - }, - networks: func( - a userT, - callback func(networkT) error, - ) error { - var ( - err error - rows *sql.Rows - ) - { - connMutex.RLock() - defer connMutex.RUnlock() - rows, err = networks(a) - } - if err != nil { - return err - } - - return networkEach(rows, callback) - }, - setNetwork: func(a memberT, b networkT) error { - connMutex.RLock() - defer connMutex.RUnlock() - return setNetwork(a, b) - }, - nipNetwork: func(a memberT) error { - connMutex.RLock() - defer connMutex.RUnlock() - return nipNetwork(a) - }, - membership: func(a userT, b networkT) (memberT, error) { - connMutex.RLock() - defer connMutex.RUnlock() - return membership(a, b) - }, - addMember: func(a memberT, b newMemberT) (memberT, error) { - connMutex.RLock() - defer connMutex.RUnlock() - return addMember(a, b) - }, - addRole: func(a memberT, b string, c memberT) error { - connMutex.RLock() - defer connMutex.RUnlock() - return addRole(a, b, c) - }, - dropRole: func(a memberT, b string, c memberT) error { - connMutex.RLock() - defer connMutex.RUnlock() - return dropRole(a, b, c) - }, - showMember: func(a memberT, b uuid.UUID) (memberT, error) { - connMutex.RLock() - defer connMutex.RUnlock() - return showMember(a, b) - }, - members: func(a memberT, callback func(memberT) error) error { - var ( - err error - rows *sql.Rows - ) - { - connMutex.RLock() - defer connMutex.RUnlock() - rows, err = members(a) - } - if err != nil { - return err - } - - return memberEach(rows, callback) - }, - editMember: func(a memberT, b memberT) error { - connMutex.RLock() - defer connMutex.RUnlock() - return editMember(a, b) - }, - dropMember: func(a memberT, b uuid.UUID) error { - connMutex.RLock() - defer connMutex.RUnlock() - return dropMember(a, b) - }, - addChannel: func( - a memberT, b newChannelT, - ) (channelT, error) { - connMutex.RLock() - defer connMutex.RUnlock() - return addChannel(a, b) - }, - channels: func( - a memberT, - callback func(channelT) error, - ) error { - var ( - err error - rows *sql.Rows - ) - { - connMutex.RLock() - defer connMutex.RUnlock() - rows, err = channels(a) - } - if err != nil { - return err - } - - return channelEach(rows, callback) - }, - setChannel: func(a memberT, b channelT) error { - connMutex.RLock() - defer connMutex.RUnlock() - return setChannel(a, b) - }, - endChannel: func(a memberT, b uuid.UUID) error { - connMutex.RLock() - defer connMutex.RUnlock() - return endChannel(a, b) - }, - join: func(a memberT, b uuid.UUID) error { - connMutex.RLock() - defer connMutex.RUnlock() - return join(a, b) - }, - part: func(a memberT, b channelT) error { - connMutex.RLock() - defer connMutex.RUnlock() - return part(a, b) - }, - names: func( - a memberT, - b uuid.UUID, - callback func(memberT) error, - ) error { - var ( - err error - rows *sql.Rows - ) - { - connMutex.RLock() - defer connMutex.RUnlock() - rows, err = names(a, b) - } - if err != nil { - return err - } - - return nameEach(rows, callback) - }, - addEvent: func(a newEventT) (eventT, error) { - connMutex.RLock() - defer connMutex.RUnlock() - return addEvent(a) - }, - allAfter: func( - a memberT, - b uuid.UUID, - callback func(eventT) error, - ) error { - var ( - err error - rows *sql.Rows - ) - { - connMutex.RLock() - defer connMutex.RUnlock() - rows, err = allAfter(a, b) - } - if err != nil { - return err - } - - return eventEach(rows, callback) - }, - logMessage: func(a userT, b messageT) error { - connMutex.Lock() - defer connMutex.Unlock() - return logMessage(a, b) - }, - close: func() error { - connMutex.Lock() - defer connMutex.Unlock() - return closeFn() - }, - }, nil -} - -func newChannelHandler(papod papodT) func(fiinha.Message) error { - return func(message fiinha.Message) error { - return nil - } -} - -func buildConsumers(prefix string) []consumerT { - return []consumerT{ - consumerT{ - topic: NEW_CHANNEL, - name: prefix + NEW_CHANNEL, - handlerFn: newChannelHandler, - }, - } -} - -func unregisterConsumers( - unsubscribeFn func(string, string), - consumers []consumerT, -) { - for _, c := range consumers { - unsubscribeFn(c.topic, c.name) - } -} - -func registerConsumers( - subscribeFn func(string, string, func(fiinha.Message) error) error, - unsubscribeFn func(string, string), - papod papodT, - consumers []consumerT, -) error { - for _, c := range consumers { - err := subscribeFn(c.topic, c.name, c.handlerFn(papod)) - if err != nil { - unregisterConsumers(unsubscribeFn, consumers) - return err - } - } - return nil -} - -func initListeners( - daemonSocketPath string, - commanderSocketPath string, -) (listenersT, error) { - daemon, err := net.Listen("unix", daemonSocketPath) - if err != nil { - return listenersT{}, err - } - - commander, err := net.Listen("unix", commanderSocketPath) - if err != nil { - daemon.Close() - return listenersT{}, err - } - - return listenersT{ - daemon: daemon, - commander: commander, - close: func() error { - return g.SomeError( - daemon.Close(), - commander.Close(), - ) - }, - }, nil -} - -// TODO: lock is global, should be by network -func newStateMutable() stateMutableT { - var rwmutex sync.RWMutex - state := stateMutableDataT{ - connections: map[uuid.UUID]*connectionT{}, - users: map[string][]uuid.UUID{}, - members: map[string]map[string][]uuid.UUID{}, - } - return stateMutableT{ - /* - connected: func(connection *connectionT) { - rwmutex.Lock() - defer rwmutex.Unlock() - state.connections[connection.uuid] = connection - }, - disconnect: func(connection *connectionT) { - { - rwmutex.Lock() - defer rwmutex.Unlock() - delete(state.connections, connection.uuid) - delete(state.users, connection.user.username) - delete(state.members, connection.user.username) - } - err := connection.conn.Close() - if err != nil { - g.Warning( - "Failed to close the connection", - "close-error", - "from", "daemon", - "err", err, - ) - } - }, - authenticated: func(connection *connectionT) { - username := connection.user.username - rwmutex.Lock() - defer rwmutex.Unlock() - if state.users[username] == nil { - state.users[username] = []uuid.UUID{} - } - state.users[username] = append( - state.users[username], - connection.uuid, - ) - }, - */ - subscribe: func( - username string, - channelNames []string, - ) { - rwmutex.Lock() - defer rwmutex.Unlock() - for _, channelName := range channelNames { - if state.members[channelName] == nil { - state.members[channelName] = - map[string][]uuid.UUID{} - } - state.members[channelName][username] = - state.users[username] - } - }, - /* - members: func(channelName string) []string { - rwmutex.RLock() - defer rwmutex.RUnlock() - usernames := make( - []string, - len(state.members[channelName]), - ) - i := 0 - for username, _ := range state.members[channelName] { - usernames[i] = username - i++ - } - return usernames - }, - connections: func(username string) []uuid.UUID { - rwmutex.RLock() - defer rwmutex.RUnlock() - connections := make( - []uuid.UUID, - len(state.users[username]), - ) - copy(connections, state.users[username]) - return connections - }, - connection: func(connectionID uuid.UUID) *connectionT { - rwmutex.RLock() - defer rwmutex.RUnlock() - return state.connections[connectionID] - }, - */ - } -} - -func newState() *stateT { - return &stateT{ - members: pds.NewMap[string, []string](nil), - users: pds.NewMap[string, []uuid.UUID](nil), - connections: pds.NewMap[uuid.UUID, connectionT](nil), - } -} - -func buildMetrics(prefix string) metricsT { - return metricsT{ - activeConnections: g.MakeGauge( - "active-connection", - "prefix", prefix, - ), - nicksInChannel: g.MakeGauge( - "nicks-in-channel", - "prefix", prefix, - ), - sendToClientError: g.MakeCounter( - "send-to-client-error", - "prefix", prefix, - ), - receivedMessage: g.MakeCounter( - "received-message", - "prefix", prefix, - ), - sentReply: g.MakeCounter( - "sent-reply", - "prefix", prefix, - ), - } -} - -func NewWithPrefix( - databasePath string, - prefix string, - daemonSocketPath string, - commanderSocketPath string, -) (IPapod, error) { - queue, err := fiinha.New(databasePath) - if err != nil { - return papodT{}, err - } - - auth, err := cracha.New(databasePath) - if err != nil { - queue.Close() - return papodT{}, err - } - - listeners, err := initListeners(daemonSocketPath, commanderSocketPath) - if err != nil { - queue.Close() - auth.Close() - return papodT{}, err - } - - queries, err := initDB(databasePath, prefix) - if err != nil { - queue.Close() - auth.Close() - listeners.close() - return papodT{}, err - } - - consumers := buildConsumers(prefix) - stateMutable := newStateMutable() - state := newState() - // receivers := makeReceivers() - metrics := buildMetrics(prefix) - // logger := g.NewLogger("prefix", prefix, "program", "papod") - - return papodT{ - queue: queue, - auth: auth, - queries: queries, - listeners: listeners, - consumers: consumers, - stateMutable: stateMutable, - state: stm.Atom(state), - // receivers: receivers, - metrics: metrics, - // logger: logger, - }, nil -} - -func New(basePath string) (IPapod, error) { - return NewWithPrefix( - basePath + "/papod.db", - defaultPrefix, - basePath + "/papod.daemon.sock", - basePath + "/papod.commander.sock", - ) -} - -func splitOnCRLF(data []byte, _atEOF bool) (int, []byte, error) { - idx := bytes.Index(data, []byte { '\r', '\n' }) - if idx == -1 { - return 0, nil, nil - } - - return idx + 2, data[0:idx], nil -} - -func splitOnRawMessage(data []byte, atEOF bool) (int, []byte, error) { - advance, token, error := splitOnCRLF(data, atEOF) - - if len(token) == 0 { - return advance, nil, error - } - - return advance, token, error -} - -func splitCommas(r rune) bool { - return r == ',' -} - -func splitSpaces(r rune) bool { - return r == ' ' -} - -func parseMessageParams(params string) []string { - const sep = " :" - - idx := strings.Index(params, sep) - if idx == -1 { - return strings.FieldsFunc(params, splitSpaces) - } else { - middle := params[:idx] - trailing := params[idx + len(sep):] - return append( - strings.FieldsFunc(middle, splitSpaces), - trailing, - ) - } -} - -func stripBlankParams(params []string) []string { - if len(params) == 1 && len(params[0]) == 0 { - return []string{} - } - - return params -} - -var messageRegex = regexp.MustCompilePOSIX( - // <prefix> <command> <params> - //1 2 3 4 - `^(:([^ ]+) +)?([a-zA-Z]+) *( .*)$`, -) -func parseMessage(rawMessage string) (messageT, error) { - var msg messageT - - components := messageRegex.FindStringSubmatch(rawMessage) - if components == nil { - return msg, errors.New("Can't parse message") - } - - msg = messageT{ - prefix: components[2], - command: components[3], - params: stripBlankParams(parseMessageParams(components[4])), - raw: rawMessage, - } - return msg, nil -} - -func asNewEvent(msg messageT) newEventT { - return newEventT{} -} - -func joinEvent(member memberT) eventT { - return eventT{} -} - -func asMessage(event eventT) messageT { - return messageT{} -} - -func asReply(event eventT) replyT { - return replyT{} -} - -func removeConnection(state *stateT, connection *connectionT) *stateT { - return state -} - -/// Is this death by a thousand goroutines? Is the runtime able to handle the -/// creation and destruction of hundreds of thousands of goroutines per second? -/// For now, we'll assume that Go's (gc) runtime, scheduler and garbage -/// collector are capable of working together to make sure this isn't a -/// catastrophe. -func broadcastMessage( - message messageT, - channelName string, - state *stateT, -) { - usernames, _ := state.members.Get(channelName) - for _, username := range usernames { - connectionIDs, _ := state.users.Get(username) - for _, connectionID := range connectionIDs { - connection, ok := state.connections.Get(connectionID) - if !ok { - continue - } - - go connection.send(message) - } - } -} - - -/* -Intentionally not implemented: - -- RPL_BOUNCE - -*/ - -// FIXME: add check for minRPL... -const minRPL_WELCOME = 0 -func _RPL_WELCOME(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "001", - params: []string{ - connection.user.username, - "Welcome to the Internet Relay Network " + - connection.user.username, - }, - } -} - -const minRPL_YOURHOST = 0 -func _RPL_YOURHOST(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "002", - params: []string{ - connection.user.username, - "Your host is FIXME, running version " + - Version, - }, - } -} - -const minRPL_CREATED = 0 -func _RPL_CREATED(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "003", - params: []string{ - connection.user.username, - "This server was create FIXME", - }, - } -} - -const minRPL_MYINFO = 0 -func _RPL_MYINFO(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "004", - params: []string{ - connection.user.username, - "FIXME " + Version + " i x", - }, - } -} - -const minRPL_UNAWAY = 0 -func _RPL_UNAWAY(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "305", - params: []string{ - connection.user.username, - "You are no longer marked as away", - }, - } -} - -const minRPL_NOWAWAY = 0 -func _RPL_NOWAWAY(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "306", - params: []string{ - connection.user.username, - "You have been marked as away", - }, - } -} - -const minRPL_WHOISUSER = 1 -func _RPL_WHOISUSER(connection *connectionT, msg messageT) replyT { - user := msg.params[0] - return replyT{ - command: "311", - params: []string{ - connection.user.username, - user, - user, - "samehost", - "*", - "my real name is: " + user, - }, - } -} - -const minRPL_WHOISSERVER = 1 -func _RPL_WHOISSERVER(connection *connectionT, msg messageT) replyT { - user := msg.params[0] - return replyT{ - command: "312", - params: []string{ - connection.user.username, - user, - "stillsamehost", - "some server info", - }, - } -} - -const minRPL_ENDOFWHOIS = 1 -func _RPL_ENDOFWHOIS(connection *connectionT, msg messageT) replyT { - user := msg.params[0] - return replyT{ - command: "318", - params: []string{ - connection.user.username, - user, - "End of WHOIS list", - }, - } -} - -const minRPL_WHOISCHANNELS = 1 -func _RPL_WHOISCHANNELS(connection *connectionT, msg messageT) replyT { - user := msg.params[0] - return replyT{ - command: "319", - params: []string{ - connection.user.username, - user, - "#default", - }, - } -} - -const minRPL_CHANNELMODEIS = 1 -func _RPL_CHANNELMODEIS(connection *connectionT, msg messageT) replyT { - channel := msg.params[0] - return replyT{ - command: "324", - params: []string{ - connection.user.username, - channel, - "+Cnst", - }, - } -} - -const minRPL_NOTOPIC = 1 -func _RPL_NOTOPIC(connection *connectionT, msg messageT) replyT { - channel := msg.params[0] - return replyT{ - command: "331", - params: []string{ - connection.user.username, - channel, - "No topic is set", - }, - } -} - -const minRPL_NAMREPLY = 1 -func _RPL_NAMREPLY(connection *connectionT, msg messageT) replyT { - channel := msg.params[0] - return replyT{ - command: "353", - params: []string{ - connection.user.username, - "=", - channel, - connection.user.username + " virtualuser", - }, - } -} - -const minRPL_ENDOFNAMES = 1 -func _RPL_ENDOFNAMES(connection *connectionT, msg messageT) replyT { - channel := msg.params[0] - return replyT{ - command: "366", - params: []string{ - connection.user.username, - channel, - "End of NAMES list", - }, - } -} - -const minERR_UNKNOWNCOMMAND = 0 -func _ERR_UNKNOWNCOMMAND(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "421", - params: []string{ - connection.user.username, - "Unknown command", - }, - } -} - -const minERR_FILEERROR = 0 -func _ERR_FILEERROR(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "424", - params: []string{ - "File error doing query on database", - }, - } -} - -const minERR_NOTREGISTERED = 0 -func _ERR_NOTREGISTERED(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "451", - params: []string{ - "You have not registered", - }, - } -} - -const minERR_NEEDMOREPARAMS = 0 -func _ERR_NEEDMOREPARAMS(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "461", - params: []string{ - msg.command, - "Not enough parameters", - }, - } -} - - -func _CAP(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "CAP", - params: []string { - "*", - "LS", - }, - } -} - -const minPONG = 0 -func _PONG(connection *connectionT, msg messageT) replyT { - return replyT{ - command: "PONG", - params: msg.params, - } -} - - -const minUSER = 4 -func handleUSER( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - return []replyT{ - _RPL_WELCOME (connection, msg), - _RPL_YOURHOST(connection, msg), - _RPL_CREATED (connection, msg), - _RPL_MYINFO (connection, msg), - }, false, nil -} - -const minNICK = 1 -func handleNICK( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - connection.user.username = msg.params[0] - return []replyT{}, false, nil -} - -const minPRIVMSG = 2 -func handlePRIVMSG( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - // FIXME: check if user is member of channel, and is authorized to post - // FIXME: adapt to handle multiple targets - - go broadcastMessage( - msg, - msg.params[0], - papod.state.Deref(), - ) - return []replyT{}, false, nil -} - -const minTOPIC = 2 -func handleTOPIC( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - return []replyT{ - _RPL_NOTOPIC(connection, msg), - }, false, nil -} - -const minJOIN = 1 -func handleJOIN( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - // FIXME: add to database - channels := strings.FieldsFunc(msg.params[0], splitCommas) - papod.stateMutable.subscribe(connection.user.username, channels) - - return []replyT{ - _RPL_NOTOPIC (connection, msg), - _RPL_NAMREPLY (connection, msg), - _RPL_ENDOFNAMES(connection, msg), - }, false, nil -} - -const minMODE = 1 -func handleMODE( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - return []replyT{ - _RPL_CHANNELMODEIS(connection, msg), - }, false, nil -} - -const minWHOIS = 1 -func handleWHOIS( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - return []replyT{ - _RPL_WHOISUSER (connection, msg), - _RPL_WHOISSERVER (connection, msg), - _RPL_WHOISCHANNELS(connection, msg), - _RPL_ENDOFWHOIS (connection, msg), - }, false, nil -} - -const minAWAY = 0 -func handleAWAY( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - replyFn := _RPL_NOWAWAY - - if len(msg.params) == 0 { - replyFn = _RPL_UNAWAY - } - - return []replyT{ - replyFn(connection, msg), - }, false, nil -} - -const minPING = 0 -func handlePING( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - return []replyT{ - _PONG(connection, msg), - }, false, nil -} - -const minQUIT = 0 -func handleQUIT( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - return []replyT{}, true, nil -} - -const minCAP = 1 -func handleCAP( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - if msg.params[0] == "END" { - return nil, false, nil - } - - return []replyT{ - _CAP(connection, msg), - }, false, nil -} - - -func authRequired( - fn func( - papodT, - *connectionT, - messageT, - ) ([]replyT, bool, error), -) func(papodT, *connectionT, messageT) ([]replyT, bool, error) { - return func( - papod papodT, - connection *connectionT, - msg messageT, - ) ([]replyT, bool, error) { - if connection.user == nil { - return []replyT{ - _ERR_NOTREGISTERED(connection, msg), - }, false, nil - } - - return fn(papod, connection, msg) - } -} - -func minArgs( - count int, - fn func( - papodT, - *connectionT, - messageT, - ) ([]replyT, bool, error), -) func(papodT, *connectionT, messageT) ([]replyT, bool, error) { - return func( - papod papodT, - connection *connectionT, - msg messageT, - ) ([]replyT, bool, error) { - if len(msg.params) < count { - return []replyT{ - _ERR_NEEDMOREPARAMS(connection, msg), - }, false, nil - } - - return fn(papod, connection, msg) - } -} - -var commands = map[string]func( - papodT, - *connectionT, - messageT, -) ([]replyT, bool, error) { - "USER": minArgs(minUSER, handleUSER), - "NICK": minArgs(minNICK, handleNICK), - "QUIT": minArgs(minQUIT, handleQUIT), - "CAP": minArgs(minCAP, handleCAP), - "AWAY": authRequired(minArgs(minAWAY, handleAWAY)), - "PRIVMSG": authRequired(minArgs(minPRIVMSG, handlePRIVMSG)), - "PING": authRequired(minArgs(minPING, handlePING)), - "JOIN": authRequired(minArgs(minJOIN, handleJOIN)), - "MODE": authRequired(minArgs(minMODE, handleMODE)), - "TOPIC": authRequired(minArgs(minTOPIC, handleTOPIC)), - "WHOIS": authRequired(minArgs(minWHOIS, handleWHOIS)), -} - -func handleUnknown( - papod papodT, - connection *connectionT, - msg messageT, -) ([]replyT, bool, error) { - // FIXME: user doesn't exist when unauthenticated - err := papod.queries.logMessage(userT{ }, msg) - if err != nil { - g.Warning( - "Failed to log message", fmt.Sprintf("%#v", msg), - "group-as", "db-write", - "handler-action", "log-and-ignore", - "connection", connection.uuid.String(), - "err", err, - ) - } - - return []replyT{ - _ERR_UNKNOWNCOMMAND(connection, msg), - }, false, nil -} - -func actionFnFor( - command string, -) func(papodT, *connectionT, messageT) ([]replyT, bool, error) { - fn := commands[command] - if fn != nil { - return fn - } - - return handleUnknown -} - -func addTrailingSeparator(strs []string) { - if len(strs) == 0 { - return - } - - last := strs[len(strs) - 1] - if strings.Contains(last, " ") && last[0] != ':' { - strs[len(strs) - 1] = ":" + last - } -} - -func (r replyT) String() string { - addTrailingSeparator(r.params) - return fmt.Sprintf( - "%s %s\r\n", - r.command, - strings.Join(r.params, " "), - ) -} - -func (m messageT) logAttributes() slog.Attr { - return slog.Group( - "message", - "prefix", m.prefix, - "raw", m.raw, - "params", m.params, - ) -} - -func (r replyT) logAttributes() slog.Attr { - return slog.Group( - "reply", - "command", r.command, - "params", r.params, - ) -} - -func processMessage( - papod papodT, - connection *connectionT, - rawMessage string, -) { - msg, err := parseMessage(rawMessage) - if err != nil { - g.Info( - "Error parsing message", "parse-message-error", - slog.Group( - "message", - "text", rawMessage, - ), - "err", err, - ) - return - } - papod.metrics.receivedMessage(msg.logAttributes()) - - var replyErrors []error - replies, shouldClose, actionErr := actionFnFor(msg.command)( - papod, - connection, - msg, - ) - for _, reply := range replies { - _, err = io.WriteString(connection.conn, reply.String()) - if err != nil { - replyErrors = append(replyErrors, err) - } - - papod.metrics.sentReply( - msg.logAttributes(), - reply.logAttributes(), - ) - } - - if actionErr != nil || len(replyErrors) != 0 { - if actionErr != nil { - g.Info( - "Handler returned error", "handler-error", - "from", "daemon", - "err", err, - ) - } - - if len(replyErrors) != 0 { - g.Info( - "Failed to send reply", "send-reply-error", - "from", "daemon", - "err", replyErrors, - ) - } - - stm.Swap(papod.state, func(state *stateT) *stateT { - return removeConnection(state, connection) - }) - err := connection.conn.Close() - if err != nil { - g.Warning( - "Failed to close the connection", - "close-error", - "from", "daemon", - "err", err, - ) - } - - return - } - - if shouldClose { - // papod.stateMutable.disconnect(connection) - } -} - -func handleConnection(papod papodT, conn net.Conn) { - connection := connectionT{ - uuid: uuid.New(), - user: &userT{}, // TODO: SASL shenanigan probably goes here - conn: conn, - } - scanner := bufio.NewScanner(conn) - scanner.Split(splitOnRawMessage) - for scanner.Scan() { - processMessage(papod, &connection, scanner.Text()) - } -} - -func daemonLoop(papod papodT) { - for { - conn, err := papod.listeners.daemon.Accept() - if err != nil { - if errors.Is(err, net.ErrClosed) { - break - } - - g.Warning( - "Error accepting daemon connection", - "accept-connection", - "from", "daemon", - "err", err, - ) - continue - } - go handleConnection(papod, conn) - } -} - -func commanderLoop(papod papodT) { - for { - conn, err := papod.listeners.commander.Accept() - if err != nil { - if errors.Is(err, net.ErrClosed) { - break - } - - g.Warning( - "Error accepting commander connection", - "accept-connection", - "from", "commander", - "err", err, - ) - continue - } - go handleConnection(papod, conn) - } -} - -func mkbgrun() (func(func()), func()) { - var wg sync.WaitGroup - bgrun := func(f func()) { - wg.Add(1) - go func() { - f() - wg.Done() - }() - } - return bgrun, wg.Wait -} - -func (papod papodT) Start() error { - err := registerConsumers( - papod.queue.Subscribe, - papod.queue.Unsubscribe, - papod, - papod.consumers, - ) - if err != nil { - return err - } - - // FIXME: papod.logger.Info - g.Info("Starting service", "lifecycle-event", - "event", "starting-server", - slog.Group( - "versions", - "gobang", g.Version, - "cracha", cracha.Version, - "fiinha", fiinha.Version, - "golite", golite.Version, - "uuid", uuid.Version, - "papod", Version, - ), - ) - - run, wait := mkbgrun() - run(func() { daemonLoop(papod) }) - run(func() { commanderLoop(papod) }) - wait() - - return nil -} - -func (papod papodT) Close() error { - // FIXME: does this wait for current handlers to wait? Well, it should. - unregisterConsumers(papod.queue.Unsubscribe, papod.consumers) - return g.WrapErrors( - papod.listeners.close(), - // papod.connCloser.closeAll(), - papod.auth.Close(), - papod.queue.Close(), - papod.queries.close(), - ) -} - -func basePathFrom(args []string) (string, error) { - if len(args) < 2 { - return os.Getwd() - } else { - return args[1], nil - } -} - - - -func Main() { - g.Init("program", "papod") - - basePath, err := basePathFrom(os.Args) - if err != nil { - fmt.Printf("%v\n", err) - os.Exit(1) - } - - ipapod, err := New(basePath) - if err != nil { - fmt.Printf("Failed to create papod: %v\n", err) - os.Exit(1) - } - - err = ipapod.Start() - if err != nil { - fmt.Printf("Failed to start: %v\n", err) - os.Exit(1) - } -} diff --git a/src/schema.edn b/src/schema.edn new file mode 100644 index 0000000..61cf5fd --- /dev/null +++ b/src/schema.edn @@ -0,0 +1,402 @@ +[#:db{:ident :papod.network/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + {:db/ident :papod.network/name, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/unique :db.unique/value, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.network/description, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + {:db/ident :papod.network/type, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.network/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.member/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.member/network, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.member/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.member/status, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.member/joined-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.member/network+nick, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs [:papod.member/network :papod.member/nick]} + #:db{:ident :papod.member-role/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.member-role/member, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.member-role/role, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.member-role/member+role, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs [:papod.member-role/member :papod.member-role/role]} + #:db{:ident :papod.channel/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.channel/network, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.channel/name, + :valueType :db.type/string, + :cardinality :db.cardinality/one, + :unique :db.unique/value} + #:db{:ident :papod.channel.type/public} + #:db{:ident :papod.channel.type/private} + #:db{:ident :papod.channel.type/unlisted} + #:db{:ident :papod.channel/type, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.channel/label, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + #:db{:ident :papod.channel/description, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + #:db{:ident :papod.channel/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + {:db/ident :papod.channel/owner, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.channel/topic, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + #:db{:ident :papod.channel/last-event-seq, + :valueType :db.type/long, + :cardinality :db.cardinality/one} + #:db{:ident :papod.access/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.access/channel, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.access/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.access/level, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.access/channel+nick+level, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs + [:papod.access/channel :papod.access/nick :papod.access/level]} + #:db{:ident :papod.memo/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + {:db/ident :papod.memo/from, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.memo/to, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.memo/content, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.memo/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.memo/read?, + :valueType :db.type/boolean, + :cardinality :db.cardinality/one} + #:db{:ident :papod.membership/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.membership/channel, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.membership/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.membership/joined-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.membership/channel+nick, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs [:papod.membership/channel :papod.membership/nick]} + #:db{:ident :papod.session/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + {:db/ident :papod.session/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.session/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.session/finished-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.connection/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.connection/process, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.connection/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.connection/finished-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.logon/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.logon/session, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.logon/connection, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.logon/session+connection, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs [:papod.logon/session :papod.logon/connection]} + #:db{:ident :papod.process/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.process/pid, + :valueType :db.type/long, + :cardinality :db.cardinality/one} + #:db{:ident :papod.process/hostname, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + #:db{:ident :papod.process/started-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.event/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.event/channel, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.event/target-nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.event/type, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.event/source-nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.event/payload, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + #:db{:ident :papod.event/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.event/seq, + :valueType :db.type/long, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.event/reply-to, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.event/edit-of, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.event/delete-of, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.reaction/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.reaction/event, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.reaction/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.reaction/emoji, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.reaction/event+nick+emoji, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs + [:papod.reaction/event + :papod.reaction/nick + :papod.reaction/emoji]} + #:db{:ident :papod.read-marker/channel, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.read-marker/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.read-marker/timestamp, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.read-marker/channel+nick, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/identity, + :tupleAttrs [:papod.read-marker/channel :papod.read-marker/nick]} + {:db/ident :papod.network/attrs, + :db.entity/attrs + #{:papod.network/id + :papod.network/type + :papod.network/created-at + :papod.network/name}} + {:db/ident :papod.member/attrs, + :db.entity/attrs + #{:papod.member/id + :papod.member/network + :papod.member/joined-at + :papod.member/status + :papod.member/nick}} + {:db/ident :papod.member-role/attrs, + :db.entity/attrs + #{:papod.member-role/id + :papod.member-role/role + :papod.member-role/member}} + {:db/ident :papod.channel/attrs, + :db.entity/attrs + #{:papod.channel/type + :papod.channel/id + :papod.channel/network + :papod.channel/created-at}} + {:db/ident :papod.access/attrs, + :db.entity/attrs + #{:papod.access/nick + :papod.access/channel + :papod.access/level + :papod.access/id}} + {:db/ident :papod.memo/attrs, + :db.entity/attrs + #{:papod.memo/id + :papod.memo/created-at + :papod.memo/from + :papod.memo/content + :papod.memo/to + :papod.memo/read?}} + {:db/ident :papod.membership/attrs, + :db.entity/attrs + #{:papod.membership/id + :papod.membership/nick + :papod.membership/joined-at + :papod.membership/channel}} + {:db/ident :papod.session/attrs, + :db.entity/attrs + #{:papod.session/created-at :papod.session/id :papod.session/nick}} + {:db/ident :papod.process/attrs, + :db.entity/attrs + #{:papod.process/started-at + :papod.process/hostname + :papod.process/pid + :papod.process/id}} + {:db/ident :papod.connection/attrs, + :db.entity/attrs + #{:papod.connection/process + :papod.connection/created-at + :papod.connection/id}} + {:db/ident :papod.logon/attrs, + :db.entity/attrs + #{:papod.logon/connection :papod.logon/session :papod.logon/id}} + {:db/ident :papod.event/attrs, + :db.entity/attrs + #{:papod.event/type + :papod.event/source-nick + :papod.event/id + :papod.event/created-at}} + {:db/ident :papod.reaction/attrs, + :db.entity/attrs + #{:papod.reaction/emoji + :papod.reaction/nick + :papod.reaction/id + :papod.reaction/event}} + #:db{:ident :papod.event/add-seq, + :fn + {:lang :clojure, + :imports [], + :requires [], + :params [db event-eid channel-uuid], + :code + "^{:line 38, :column 6} (let [channel-eid ^{:line 38, :column 24} (or ^{:line 38, :column 28} (ffirst ^{:line 39, :column 29} (d/q (quote {:find [?e], :in [$ ?id], :where [[?e :papod.channel/id ?id]]}) db channel-uuid)) ^{:line 43, :column 28} (d/tempid :db.part/user)) last-seq ^{:line 44, :column 24} (or ^{:line 44, :column 28} (:papod.channel/last-event-seq ^{:line 45, :column 29} (d/entity db channel-eid)) 0) new-seq ^{:line 46, :column 24} (inc last-seq)] [[:db/add channel-eid :papod.channel/id channel-uuid] [:db/add channel-eid :papod.channel/last-event-seq new-seq] [:db/add event-eid :papod.event/seq new-seq]])", + :fnref #<Delay@65b5b5ed: :not-delivered>}} + #:db{:ident :papod.access/ensure-unique, + :fn + {:lang :clojure, + :imports [], + :requires [], + :params [db channel nick level], + :code + "^{:line 56, :column 6} (when ^{:line 56, :column 12} (seq ^{:line 57, :column 13} (d/q (quote {:find [?a], :in [$ ?ch ?n ?l], :where [[?a :papod.access/channel ?ch] [?a :papod.access/nick ?n] [?a :papod.access/level ?l]]}) db channel nick level)) ^{:line 63, :column 8} (d/cancel #:cognitect.anomalies{:category :cognitect.anomalies/conflict, :message \"Duplicate access entry\"}))", + :fnref #<Delay@df432ec: :not-delivered>}}] diff --git a/tests/acceptance.sh b/tests/acceptance.sh new file mode 100755 index 0000000..b7d8ea0 --- /dev/null +++ b/tests/acceptance.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +# Acceptance tests: run irctest against papod (direct TCP) + +PORT="${IRCTEST_PORT:-16667}" +IRCTEST_DIR="${IRCTEST_DIR:-$(dirname "$0")/../STUFF/irctest}" +JARDEPS="cracha fiinha jasm labareda peer dtmc base clojure" +CLASSPATH="papod.jar$(printf ':/home/andreh/.usr/var/mkg/share/java/%s.jar' $JARDEPS)" + +PAPOD_PID="" + +cleanup() { + [ -n "$PAPOD_PID" ] && kill "$PAPOD_PID" 2>/dev/null || true + wait "$PAPOD_PID" 2>/dev/null || true +} +trap cleanup EXIT + +PAPOD_TCP_PORT="$PORT" \ +PAPOD_SERVER_NAME=My.Little.Server \ +PAPOD_IDLE_TIMEOUT=1500 \ + java -client -cp "$CLASSPATH" papod 2>/dev/null & +PAPOD_PID=$! + +for i in $(seq 1 50); do + nc -z 127.0.0.1 "$PORT" 2>/dev/null && break + sleep 0.1 +done + +if ! nc -z 127.0.0.1 "$PORT" 2>/dev/null; then + echo "ERROR: papod TCP port $PORT not listening" >&2 + exit 1 +fi + +cd "$IRCTEST_DIR" +export IRCTEST_SERVER_HOSTNAME=127.0.0.1 +export IRCTEST_SERVER_PORT=$PORT + +exec pytest \ + --controller irctest.controllers.external_server \ + "$@" diff --git a/tests/benchmarks/join-leave/main.go b/tests/benchmarks/join-leave/main.go deleted file mode 120000 index f67563d..0000000 --- a/tests/benchmarks/join-leave/main.go +++ /dev/null @@ -1 +0,0 @@ -../../main.go
\ No newline at end of file diff --git a/tests/benchmarks/join-leave/papod.go b/tests/benchmarks/join-leave/papod.go deleted file mode 100644 index 7bb69cc..0000000 --- a/tests/benchmarks/join-leave/papod.go +++ /dev/null @@ -1,23 +0,0 @@ -package papod - -import ( - "flag" - "time" -) - - - -var nFlag = flag.Int( - "n", - 1_000, - "The number of iterations to execute", -) - -func MainTest() { - flag.Parse() - n := *nFlag - - for i := 0; i < n; i++ { - time.Sleep(time.Millisecond * 1) - } -} diff --git a/tests/cli-opts.sh b/tests/cli-opts.sh deleted file mode 100755 index fcb62ca..0000000 --- a/tests/cli-opts.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -set -eu - -exit diff --git a/tests/functional/multiple-channels-and-users/main.go b/tests/functional/multiple-channels-and-users/main.go deleted file mode 120000 index f67563d..0000000 --- a/tests/functional/multiple-channels-and-users/main.go +++ /dev/null @@ -1 +0,0 @@ -../../main.go
\ No newline at end of file diff --git a/tests/functional/multiple-channels-and-users/papod.go b/tests/functional/multiple-channels-and-users/papod.go deleted file mode 100644 index 6f6a67b..0000000 --- a/tests/functional/multiple-channels-and-users/papod.go +++ /dev/null @@ -1,12 +0,0 @@ -package papod - -import ( - g "gobang" -) - - - -func MainTest() { - g.Testing("multiple users can join a channel", func() { - }) -} diff --git a/tests/fuzz/api-check/main.go b/tests/fuzz/api-check/main.go deleted file mode 120000 index f67563d..0000000 --- a/tests/fuzz/api-check/main.go +++ /dev/null @@ -1 +0,0 @@ -../../main.go
\ No newline at end of file diff --git a/tests/fuzz/api-check/papod.go b/tests/fuzz/api-check/papod.go deleted file mode 100644 index edab81c..0000000 --- a/tests/fuzz/api-check/papod.go +++ /dev/null @@ -1,34 +0,0 @@ -package papod - -import ( - "os" - "testing" - "testing/internal/testdeps" -) - - - -func api(f *testing.F) { - f.Fuzz(func(t *testing.T, n int) { - if n > 1 { - if n < 2 { - t.Errorf("Failed n: %v\n", n) - } - } - }) -} - - - -func MainTest() { - fuzzTargets := []testing.InternalFuzzTarget{ - { "api", api }, - } - - deps := testdeps.TestDeps{} - tests := []testing.InternalTest {} - benchmarks := []testing.InternalBenchmark{} - examples := []testing.InternalExample {} - m := testing.MainStart(deps, tests, benchmarks, fuzzTargets, examples) - os.Exit(m.Run()) -} diff --git a/tests/fuzz/api.clj b/tests/fuzz/api.clj new file mode 100644 index 0000000..d389c13 --- /dev/null +++ b/tests/fuzz/api.clj @@ -0,0 +1,11 @@ +(ns fuzz.api + (:require [base]) + (:gen-class)) + + + +(defn -main + [& args] + (base/run-fuzz-loop (System/currentTimeMillis) + (base/duration-ms-from-args args) + (fn [_i] (base/uuidv7)))) diff --git a/tests/integration.clj b/tests/integration.clj new file mode 100644 index 0000000..7b20575 --- /dev/null +++ b/tests/integration.clj @@ -0,0 +1,572 @@ +(ns integration + (:require [clojure.test :as t :refer [deftest is testing]] + [clojure.string :as string] + [cracha] + [datomic.api :as d] + [fiinha] + [papod]) + (:gen-class)) + + + +(defn- test-components + [] + (let [fiinha-state (fiinha/initdb! + (str "datomic:mem://fiinha-int-" + (java.util.UUID/randomUUID))) + cracha-state (cracha/init! + (str "datomic:mem://cracha-int-" + (java.util.UUID/randomUUID)) + fiinha-state) + papod-uri (str "datomic:mem://papod-int-" + (java.util.UUID/randomUUID)) + _ (d/create-database papod-uri) + conn (d/connect papod-uri) + _ @(d/transact conn @#'papod/schema) + process-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.process/attrs + :papod.process/id process-id + :papod.process/pid 0 + :papod.process/hostname "test" + :papod.process/started-at (java.util.Date.)}]) + net-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.network/attrs + :papod.network/id net-id + :papod.network/name "test-network" + :papod.network/description "" + :papod.network/type "public" + :papod.network/created-at (java.util.Date.)}])] + {:conn conn + :cracha cracha-state + :process-id process-id + :net-id net-id + :clients (atom {}) + :channels (atom {}) + :ops (atom {}) + :voiced (atom {}) + :chan-modes (atom {}) + :chan-keys (atom {}) + :chan-limits (atom {}) + :invites (atom {}) + :whowas (atom []) + :max-users (atom 0) + :n-unreg (atom 0)})) + +(defn- make-client + "Creates a simulated client connection using piped streams. + Spawns client-loop! in a thread. Returns a map with :out (writer) + and :client-out (ByteArrayOutputStream of server responses)." + [components] + (let [client-to-server (java.io.PipedOutputStream.) + server-reads (java.io.PipedInputStream. client-to-server) + server-writes (java.io.ByteArrayOutputStream.) + ;; client-loop! reads from server-reads, writes to server-writes + ;; We replicate client-loop! inline to avoid needing a socket + thread + (Thread. + (fn [] + (try + (let [r server-reads + w server-writes + b (make-array Byte/TYPE 1024) + conn-id (java.util.UUID/randomUUID) + client (atom {:nick nil :user nil :pass nil + :registered? false + :w w :connection-id conn-id})] + (when-let [conn (:conn components)] + @(d/transact conn + [{:db/ensure :papod.connection/attrs + :papod.connection/id conn-id + :papod.connection/process [:papod.process/id + (:process-id components)] + :papod.connection/created-at (java.util.Date.)}])) + (try + (loop [acc ""] + (let [n (.read r b)] + (when (pos? n) + (recur (@#'papod/process-input! + (str acc (String. b 0 n "UTF-8")) + w client components))))) + (finally + (when-let [conn (:conn components)] + @(d/transact conn + [[:db/add [:papod.connection/id conn-id] + :papod.connection/finished-at (java.util.Date.)]])) + (when-let [nick (:nick @client)] + (when (:clients components) + (swap! (:clients components) dissoc nick)) + (when (:channels components) + (doseq [[ch _] @(:channels components)] + (swap! (:channels components) update ch disj nick))))))) + (catch Exception _))))] + (.setDaemon thread true) + (.start thread) + {:out (java.io.OutputStreamWriter. client-to-server "UTF-8") + :client-out server-writes + :thread thread + :close! (fn [] + (.close client-to-server) + (.join thread 1000))})) + +(defn- send! + [{:keys [out]} line] + (.write out (str line "\r\n")) + (.flush out)) + +(defn- drain! + "Returns all output the server has sent so far, then resets the buffer." + [{:keys [client-out]}] + (Thread/sleep 50) ;; give server a moment to process + (let [s (.toString client-out "UTF-8")] + (.reset client-out) + s)) + +(defn- wait-for + "Wait until server output contains the expected substring." + [client expected timeout-ms] + (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (loop [] + (let [output (.toString (:client-out client) "UTF-8")] + (if (string/includes? output expected) + output + (if (> (System/currentTimeMillis) deadline) + output + (do (Thread/sleep 20) + (recur)))))))) + +(defn- register! + [client nick net-id] + (send! client (str "PASS " net-id)) + (send! client (str "NICK " nick)) + (send! client (str "USER " nick " 0 * :" nick))) + + + +(deftest test_full-registration + (let [components (test-components) + client (make-client components)] + (try + (register! client "alice" (:net-id components)) + (let [output (wait-for client "001" 2000)] + (is (string/includes? output "001")) + (is (string/includes? output "Welcome"))) + (finally + ((:close! client)))))) + +(deftest test_channel-messaging + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + ;; Register both + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + ;; Alice joins + (send! alice "JOIN #test") + (wait-for alice "JOIN" 2000) + ;; Bob joins + (.reset (:client-out alice)) + (send! bob "JOIN #test") + (wait-for bob "JOIN" 2000) + ;; Alice should see bob's join + (let [alice-out (wait-for alice "JOIN" 2000)] + (is (string/includes? alice-out "bob"))) + ;; Alice sends — bob receives + (.reset (:client-out bob)) + (send! alice "PRIVMSG #test :Hello from Alice!") + (let [bob-out (wait-for bob "Hello from Alice" 2000)] + (is (string/includes? bob-out "PRIVMSG #test")) + (is (string/includes? bob-out "Hello from Alice"))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_ping-pong + (let [components (test-components) + client (make-client components)] + (try + (send! client "PING :test123") + (let [output (wait-for client "PONG" 2000)] + (is (string/includes? output "PONG")) + (is (string/includes? output "test123"))) + (finally + ((:close! client)))))) + +(deftest test_no-network-blocks-join + (let [components (test-components) + client (make-client components)] + (try + ;; Register WITHOUT PASS (no network) + (send! client "NICK alice") + (send! client "USER alice 0 * :Alice") + (wait-for client "001" 2000) + ;; JOIN should fail + (send! client "JOIN #test") + (let [output (wait-for client "network" 2000)] + (is (string/includes? output "No network selected"))) + (finally + ((:close! client)))))) + +(deftest test_nickserv-register-and-identify + (let [components (test-components) + client (make-client components)] + (try + (register! client "alice" (:net-id components)) + (wait-for client "001" 2000) + ;; Register nick + (send! client "PRIVMSG NickServ :REGISTER mypass") + (let [output (wait-for client "registered" 5000)] + (is (string/includes? output "registered successfully"))) + ;; Identify + (.reset (:client-out client)) + (send! client "PRIVMSG NickServ :IDENTIFY mypass") + (let [output (wait-for client "identified" 2000)] + (is (string/includes? output "now identified")) + (is (string/includes? output "Session ID"))) + (finally + ((:close! client)))))) + +(deftest test_echo-message + (let [components (test-components) + client (make-client components) + bob (make-client components)] + (try + (register! client "alice" (:net-id components)) + (wait-for client "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + ;; Negotiate echo-message + ;; (CAP must be before registration, but we can test via direct cap set) + ;; For a proper test, use the full CAP flow: + ;; For now, join a channel and test without echo + (send! client "JOIN #test") + (wait-for client "JOIN" 2000) + (send! bob "JOIN #test") + (wait-for bob "JOIN" 2000) + ;; Without echo: alice doesn't see her own message + (.reset (:client-out client)) + (send! client "PRIVMSG #test :no-echo") + (Thread/sleep 100) + (let [alice-out (.toString (:client-out client) "UTF-8")] + (is (not (string/includes? alice-out "no-echo")))) + (finally + ((:close! client)) + ((:close! bob)))))) + +(deftest test_private-message-dm + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + ;; Alice DMs bob + (.reset (:client-out bob)) + (send! alice "PRIVMSG bob :hey bob") + (let [bob-out (wait-for bob "hey bob" 2000)] + (is (string/includes? bob-out "PRIVMSG bob :hey bob"))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_chanserv-register-and-kick + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + (send! alice "JOIN #modtest") + (wait-for alice "JOIN" 2000) + (send! bob "JOIN #modtest") + (wait-for bob "JOIN" 2000) + ;; Alice registers channel + (send! alice "PRIVMSG ChanServ :REGISTER #modtest") + (wait-for alice "registered" 2000) + ;; Alice kicks bob + (.reset (:client-out bob)) + (send! alice "PRIVMSG ChanServ :KICK #modtest bob :behave") + (let [bob-out (wait-for bob "KICK" 2000)] + (is (string/includes? bob-out "KICK")) + (is (string/includes? bob-out "behave"))) + (finally + ((:close! alice)) + ((:close! bob)))))) + + +(deftest test_quit-notifies-channel + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + (send! alice "JOIN #qtest") + (wait-for alice "JOIN" 2000) + (send! bob "JOIN #qtest") + (wait-for bob "JOIN" 2000) + ;; Bob quits + (.reset (:client-out alice)) + (send! bob "QUIT :bye!") + (let [alice-out (wait-for alice "QUIT" 2000)] + (is (string/includes? alice-out "QUIT")) + (is (string/includes? alice-out "bye!"))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_notice-channel + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + (send! alice "JOIN #ntest") + (wait-for alice "JOIN" 2000) + (send! bob "JOIN #ntest") + (wait-for bob "JOIN" 2000) + (.reset (:client-out bob)) + (send! alice "NOTICE #ntest :hello notice") + (let [bob-out (wait-for bob "hello notice" 2000)] + (is (string/includes? bob-out "NOTICE #ntest")) + (is (string/includes? bob-out "hello notice"))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_kick-irc-command + (let [components (test-components) + alice (make-client components) + bob (make-client components) + baz (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + (register! baz "baz" (:net-id components)) + (wait-for baz "001" 2000) + (send! alice "JOIN #kick") + (wait-for alice "JOIN" 2000) + (send! bob "JOIN #kick") + (wait-for bob "JOIN" 2000) + (send! baz "JOIN #kick") + (wait-for baz "JOIN" 2000) + (.reset (:client-out alice)) + (.reset (:client-out bob)) + (.reset (:client-out baz)) + (send! alice "KICK #kick bob :bye") + ;; All three see the KICK + (let [bob-out (wait-for bob "KICK" 2000)] + (is (string/includes? bob-out "KICK #kick bob :bye"))) + (let [baz-out (wait-for baz "KICK" 2000)] + (is (string/includes? baz-out "KICK #kick bob"))) + (let [alice-out (wait-for alice "KICK" 2000)] + (is (string/includes? alice-out "KICK #kick bob"))) + (finally + ((:close! alice)) + ((:close! bob)) + ((:close! baz)))))) + +(deftest test_topic-broadcast + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + (send! alice "JOIN #topic") + (wait-for alice "JOIN" 2000) + (send! bob "JOIN #topic") + (wait-for bob "JOIN" 2000) + (.reset (:client-out alice)) + (.reset (:client-out bob)) + ;; Alice sets topic + (send! alice "TOPIC #topic :New topic here") + ;; Both see it + (let [bob-out (wait-for bob "TOPIC" 2000)] + (is (string/includes? bob-out + "TOPIC #topic :New topic here"))) + (let [alice-out (wait-for alice "TOPIC" 2000)] + (is (string/includes? alice-out + "TOPIC #topic :New topic here"))) + ;; Query returns the topic + (.reset (:client-out alice)) + (send! alice "TOPIC #topic") + (let [reply (wait-for alice "332" 2000)] + (is (string/includes? reply "New topic here"))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_part-broadcast + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + (send! alice "JOIN #part") + (wait-for alice "JOIN" 2000) + (send! bob "JOIN #part") + (wait-for bob "JOIN" 2000) + (.reset (:client-out alice)) + (.reset (:client-out bob)) + ;; Bob parts with message + (send! bob "PART #part :leaving now") + (let [alice-out (wait-for alice "PART" 2000)] + (is (string/includes? alice-out + "bob PART #part :leaving now"))) + (let [bob-out (wait-for bob "PART" 2000)] + (is (string/includes? bob-out "PART #part"))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_whois-between-clients + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + (.reset (:client-out alice)) + (send! alice "WHOIS bob") + (let [out (wait-for alice "318" 2000)] + (is (string/includes? out " 311 ")) + (is (string/includes? out "bob")) + (is (string/includes? out " 318 "))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_who-channel + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + (send! alice "JOIN #who") + (wait-for alice "JOIN" 2000) + (send! bob "JOIN #who") + (wait-for bob "JOIN" 2000) + (.reset (:client-out alice)) + (send! alice "WHO #who") + (let [out (wait-for alice "315" 2000)] + ;; Two 352 replies (alice + bob) + one 315 + (is (string/includes? out " 352 ")) + (is (string/includes? out "alice")) + (is (string/includes? out "bob")) + (is (string/includes? out " 315 "))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_nick-collision + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + ;; Bob tries the same nick + (send! bob (str "PASS " (:net-id components))) + (send! bob "NICK alice") + (send! bob "USER bob 0 * :bob") + (let [out (wait-for bob "433" 2000)] + (is (string/includes? out "433")) + (is (string/includes? out "already in use"))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_invite-only-channel + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + ;; Alice creates and locks channel + (send! alice "JOIN #priv") + (wait-for alice "JOIN" 2000) + (send! alice "MODE #priv +i") + (Thread/sleep 100) + ;; Bob tries to JOIN — denied + (send! bob "JOIN #priv") + (let [out (wait-for bob "473" 2000)] + (is (string/includes? out "473")) + (is (string/includes? out "+i"))) + ;; Alice invites bob + (.reset (:client-out bob)) + (send! alice "INVITE bob #priv") + (wait-for bob "INVITE" 2000) + ;; Bob can now join + (.reset (:client-out bob)) + (send! bob "JOIN #priv") + (let [out (wait-for bob "JOIN" 2000)] + (is (string/includes? out "JOIN")) + (is (not (string/includes? out "473")))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_isupport-005 + (let [components (test-components) + client (make-client components)] + (try + (register! client "alice" (:net-id components)) + (let [out (wait-for client "PREFIX=" 2000)] + (is (string/includes? out "PREFIX=(ov)@+")) + (is (string/includes? out "CHANTYPES=#")) + (is (string/includes? out "CASEMAPPING=ascii"))) + (finally + ((:close! client)))))) + +(deftest test_oper-success + (let [components (test-components) + client (make-client components)] + (try + (register! client "baz" (:net-id components)) + (wait-for client "001" 2000) + (.reset (:client-out client)) + (send! client "OPER operuser operpassword") + (let [out (wait-for client "MODE" 2000)] + (is (string/includes? out "381")) + (is (re-find #"MODE baz :?\+o" out))) + (finally + ((:close! client)))))) + +(defn -main + [& _args] + (binding [*out* *err*] + (let [{:keys [fail error] :as res} (t/run-tests 'integration) + status (if (zero? (+ fail error)) + 0 + 1)] + (prn res) + (System/exit status)))) diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100644 index 0000000..649c61d --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,122 @@ +#!/bin/sh + + +export TMPDIR="${TMPDIR:-$XDG_RUNTIME_DIR}" + +end="\033[0m" +red="\033[0;31m" +green="\033[0;32m" +yellow="\033[0;33m" + +N= +OUT= +ERR= +STATUS= + +ERROR() { + # shellcheck disable=2059 + printf "${red}ERROR${end}" +} + +print_debug_info() { + # shellcheck disable=2016 + printf 'LINENO: %s\n$OUT: %s\n$ERR: %s\n' \ + "$N" "$OUT" "$ERR" >&2 +} + +assert_status() { + if [ "$STATUS" != "$1" ]; then + printf '\n%s: Bad status.\n\nexpected: %s\ngot: %s\n' \ + "$(ERROR)" "$1" "$STATUS" >&2 + print_debug_info + exit 1 + fi +} + +assert_usage() { + if ! grep -Fq 'Usage' "$1"; then + echo 'Expected to find "Usage" text, it was missing:' >&2 + cat "$1" >&2 + print_debug_info + exit 1 + fi +} + +assert_empty_stream() { + if [ -s "$2" ]; then + FMT='\n%s: Expected %s (%s) to be empty, but has content:\n%s\n' + # shellcheck disable=2059 + printf "$FMT" \ + "$(ERROR)" "$1" "$2" "$(cat "$2")" >&2 + print_debug_info + exit 1 + fi +} + +assert_empty_stdout() { + assert_empty_stream STDOUT "$OUT" +} + +assert_empty_stderr() { + assert_empty_stream STDERR "$ERR" +} + +assert_stream() { + if [ "$(cat "$2")" != "$3" ]; then + printf '\n%s: Bad %s (%s)\n\nexpected: %s\ngot: %s\n' \ + "$(ERROR)" "$1" "$2" "$3" "$(cat "$2")" >&2 + print_debug_info + exit 1 + fi +} + +assert_stdout() { + assert_stream STDOUT "$OUT" "$1" +} + +assert_stderr() { + assert_stream STDERR "$ERR" "$1" +} + +assert_grep_stream() { + if ! grep -qE "$3" "$2"; then + printf '\n%s: Bad %s (%s)\n\ngrepping: %s\nin:\n%s\n' \ + "$(ERROR)" "$1" "$2" "$3" "$(cat "$2")" >&2 + print_debug_info + exit 1 + fi +} + +assert_grep_stdout() { + assert_grep_stream STDOUT "$OUT" "$1" +} + +assert_grep_stderr() { + assert_grep_stream STDERR "$ERR" "$1" +} + +assert_fgrep_stream() { + if ! grep -Fq -- "$3" "$2"; then + printf '\n%s: Bad %s (%s)\n\ngrepping: %s\nin:\n%s\n' \ + "$(ERROR)" "$1" "$2" "$3" "$(cat "$2")" >&2 + print_debug_info + exit 1 + fi +} + +assert_fgrep_stdout() { + assert_fgrep_stream STDOUT "$OUT" "$1" +} + +assert_fgrep_stderr() { + assert_fgrep_stream STDERR "$ERR" "$1" +} + +testing() { + printf "${yellow}testing${end}: %s..." "$1" >&2 +} + +test_ok() { + # shellcheck disable=2059 + printf " ${green}OK${end}.\n" >&2 +} diff --git a/tests/main.go b/tests/main.go deleted file mode 100644 index f32854e..0000000 --- a/tests/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "papod" - -func main() { - papod.MainTest() -} diff --git a/tests/papod.go b/tests/papod.go deleted file mode 100644 index 3e82d82..0000000 --- a/tests/papod.go +++ /dev/null @@ -1,5756 +0,0 @@ -package papod - -import ( - "bufio" - "crypto/rand" - "database/sql" - "errors" - "fmt" - "io" - "os" - "reflect" - "strings" - "time" - - "golite" - "uuid" - g "gobang" -) - - - -type userChangeT struct{ - id int64 - timestamp time.Time - user_id int64 - attribute string - valueStr *string - valueBlob *uuid.UUID - valueBool *bool - op bool -} - -type networkChangeT struct{ - id int64 - timestamp time.Time - network_id int64 - attribute string - value string - op bool -} - -type channelChangeT struct{ - id int64 - timestamp time.Time - channel_id int64 - attribute string - value string - op bool -} - - - -func userChangesSQL(prefix string) string { - const tmpl = ` - SELECT - "%s_user_changes".id, - "%s_user_changes".timestamp, - "%s_user_changes".user_id, - "%s_user_changes".attribute, - "%s_user_changes".value_text, - "%s_user_changes".value_blob, - "%s_user_changes".value_bool, - "%s_user_changes".op - FROM "%s_user_changes" - JOIN "%s_users" ON - "%s_user_changes".user_id = "%s_users".id - WHERE "%s_users".user_uuid = ? - ORDER BY "%s_user_changes".id ASC; - ` - return fmt.Sprintf( - tmpl, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - ) -} - -func makeUserChanges(db *sql.DB, prefix string) func(uuid.UUID) []userChangeT { - q := userChangesSQL(prefix) - - return func(userID uuid.UUID) []userChangeT { - userChanges := []userChangeT{} - rows, err := db.Query(q, userID[:]) - g.TErrorIf(err) - defer rows.Close() - - for rows.Next() { - userChange := userChangeT{} - var ( - timestr string - value_bytes []byte - ) - err := rows.Scan( - &userChange.id, - ×tr, - &userChange.user_id, - &userChange.attribute, - &userChange.valueStr, - &value_bytes, - &userChange.valueBool, - &userChange.op, - ) - g.TErrorIf(err) - - if value_bytes != nil { - valueBlob := uuid.UUID(value_bytes) - userChange.valueBlob = &valueBlob - } - - userChange.timestamp, err = time.Parse( - time.RFC3339Nano, - timestr, - ) - g.TErrorIf(err) - - userChanges = append(userChanges, userChange) - } - - g.TErrorIf(rows.Err()) - return userChanges - } -} - -func networkChangesSQL(prefix string) string { - const tmpl = ` - SELECT - "%s_network_changes".id, - "%s_network_changes".timestamp, - "%s_network_changes".network_id, - "%s_network_changes".attribute, - "%s_network_changes".value, - "%s_network_changes".op - FROM "%s_network_changes" - JOIN "%s_networks" ON - "%s_network_changes".network_id = "%s_networks".id - WHERE "%s_networks".uuid = ? - ORDER BY "%s_network_changes".id ASC; - ` - return fmt.Sprintf( - tmpl, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - prefix, - ) -} - -func makeNetworkChanges( - db *sql.DB, - prefix string, -) func(uuid.UUID) []networkChangeT { - q := networkChangesSQL(prefix) - - return func(networkID uuid.UUID) []networkChangeT { - networkChanges := []networkChangeT{} - rows, err := db.Query(q, networkID[:]) - g.TErrorIf(err) - defer rows.Close() - - for rows.Next() { - networkChange := networkChangeT{} - var timestr string - err := rows.Scan( - &networkChange.id, - ×tr, - &networkChange.network_id, - &networkChange.attribute, - &networkChange.value, - &networkChange.op, - ) - g.TErrorIf(err) - - networkChange.timestamp, err = time.Parse( - time.RFC3339Nano, - timestr, - ) - g.TErrorIf(err) - - networkChanges = append(networkChanges, networkChange) - } - - g.TErrorIf(rows.Err()) - return networkChanges - } -} - -func channelChangesSQL(prefix string) string { - const tmpl = ` - SELECT - id, - timestamp, - channel_id, - attribute, - value_text, - value_bool, - op - FROM "%s_channel_changes" - WHERE channel_id = ? - ORDER BY id ASC; - ` - return fmt.Sprintf(tmpl, prefix) -} - -func makeChannelChanges( - db *sql.DB, - prefix string, -) func(int64) []channelChangeT { - q := channelChangesSQL(prefix) - - return func(id int64) []channelChangeT { - channelChanges := []channelChangeT{} - rows, err := db.Query(q, id) - g.TErrorIf(err) - defer rows.Close() - - for rows.Next() { - channelChange := channelChangeT{} - var ( - timestr string - valueString sql.NullString - valueBool sql.NullBool - ) - err := rows.Scan( - &channelChange.id, - ×tr, - &channelChange.channel_id, - &channelChange.attribute, - &valueString, - &valueBool, - &channelChange.op, - ) - g.TErrorIf(err) - - if valueString.Valid { - channelChange.value = valueString.String - } else if valueBool.Valid { - if valueBool.Bool { - channelChange.value = "true" - } else { - channelChange.value = "false" - } - } - - channelChange.timestamp, err = time.Parse( - time.RFC3339Nano, - timestr, - ) - g.TErrorIf(err) - - channelChanges = append(channelChanges, channelChange) - } - - g.TErrorIf(rows.Err()) - return channelChanges - } -} - -func mknstring(n int) string { - buffer := make([]byte, n) - _, err := io.ReadFull(rand.Reader, buffer) - g.TErrorIf(err) - return string(buffer) -} - -func mkstring() string { - return mknstring(32) -} - - -func test_defaultPrefix() { - g.TestStart("defaultPrefix") - - g.Testing("the defaultPrefix is valid", func() { - g.TErrorIf(g.ValidateSQLTablePrefix(defaultPrefix)) - }) -} - -func test_tryRollback() { - g.TestStart("tryRollback()") - - myErr := errors.New("bottom error") - - db, err := sql.Open(golite.DriverName, golite.InMemory) - g.TErrorIf(err) - defer db.Close() - - - g.Testing("the error is propagated if rollback doesn't fail", func() { - tx, err := db.Begin() - g.TErrorIf(err) - - err = tryRollback(tx, myErr) - g.TAssertEqual(err, myErr) - }) - - g.Testing("a wrapped error when rollback fails", func() { - tx, err := db.Begin() - g.TErrorIf(err) - - err = tx.Commit() - g.TErrorIf(err) - - err = tryRollback(tx, myErr) - g.TAssertEqual(reflect.DeepEqual(err, myErr), false) - g.TAssertEqual(errors.Is(err, myErr), true) - }) -} - -func test_inTx() { - g.TestStart("inTx()") - - db, err := sql.Open(golite.DriverName, golite.InMemory) - g.TErrorIf(err) - defer db.Close() - - - g.Testing("when fn() errors, we propagate it", func() { - myErr := errors.New("to be propagated") - err := inTx(db, func(tx *sql.Tx) error { - return myErr - }) - g.TAssertEqual(err, myErr) - }) - - g.Testing("on nil error we get nil", func() { - err := inTx(db, func(tx *sql.Tx) error { - return nil - }) - g.TErrorIf(err) - }) -} - -func test_createTables() { - g.TestStart("createTables()") - - db, err := sql.Open(golite.DriverName, golite.InMemory) - g.TErrorIf(err) - defer db.Close() - - - g.Testing("tables exist afterwards", func() { - const tmpl_read = ` - SELECT id FROM "%s_channel_events" LIMIT 1; - ` - qRead := fmt.Sprintf(tmpl_read, defaultPrefix) - - _, err := db.Exec(qRead) - g.TErrorNil(err) - - err = createTables(db, defaultPrefix) - g.TErrorIf(err) - - _, err = db.Exec(qRead) - g.TErrorIf(err) - }) - - g.Testing("we can do it multiple times", func() { - g.TErrorIf(g.SomeError( - createTables(db, defaultPrefix), - createTables(db, defaultPrefix), - createTables(db, defaultPrefix), - )) - }) -} - -func test_createUserStmt() { - g.TestStart("createUserStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - g.TErrorIf(createUserErr) - defer g.SomeFnError( - createUserClose, - db.Close, - ) - - userChanges := makeUserChanges(db, prefix) - - - g.Testing("userID's must be unique", func() { - newUser := newUserT{ - uuid: uuid.New(), - } - - _, err1 := createUser(newUser) - _, err2 := createUser(newUser) - g.TErrorIf(err1) - g.TAssertEqual( - err2.(golite.Error).ExtendedCode, - golite.ErrConstraintUnique, - ) - }) - - g.Testing("a new user starts without a pictureID", func() { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - g.TAssertEqual(user.pictureID == nil, true) - }) - - g.Testing("the database fills default and generated values", func() { - newUser := newUserT{ - uuid: uuid.New(), - username: "the username", - displayName: "the display name", - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - g.TAssertEqual(user.id == 0, false) - g.TAssertEqual(user.timestamp == time.Time{}, false) - g.TAssertEqual(user.uuid, newUser.uuid) - g.TAssertEqual(user.username, "the username") - g.TAssertEqual(user.displayName, "the display name") - }) - - g.Testing("users can have duplicate names and usernames", func() { - username := mkstring() - displayName := mkstring() - - newUser1 := newUserT{ - uuid: uuid.New(), - username: username, - displayName: displayName, - } - - newUser2 := newUserT{ - uuid: uuid.New(), - username: username, - displayName: displayName, - } - - _, err1 := createUser(newUser1) - _, err2 := createUser(newUser2) - g.TErrorIf(err1) - g.TErrorIf(err2) - }) - - g.Testing("new user trigger inserts into *_user_changes", func() { - newUser := newUserT{ - uuid: uuid.New(), - username: mkstring(), - displayName: mkstring(), - } - - _, err := createUser(newUser) - g.TErrorIf(err) - - changes := userChanges(newUser.uuid) - g.TAssertEqual(len(changes), 3) - g.TAssertEqual( - []string{ - changes[0].attribute, - changes[1].attribute, - changes[2].attribute, - *changes[0].valueStr, - *changes[1].valueStr, - }, - []string{ - "username", - "display_name", - "deleted", - newUser.username, - newUser.displayName, - }, - ) - g.TAssertEqual(*changes[2].valueBool, false) - g.TAssertEqual( - []bool{ - changes[0].op, - changes[1].op, - changes[2].op, - changes[0].valueBlob == nil, - changes[0].valueBool == nil, - changes[1].valueBlob == nil, - changes[1].valueBool == nil, - changes[2].valueStr == nil, - changes[2].valueBlob == nil, - }, - []bool{ - true, - true, - true, - true, - true, - true, - true, - true, - true, - }, - ) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - createUserClose(), - createUserClose(), - createUserClose(), - )) - }) -} - -func test_userByUUIDStmt() { - g.TestStart("userByUUIDStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - userByUUID, userByUUIDClose, userByUUIDErr := userByUUIDStmt(cfg) - deleteUser, deleteUserClose, deleteUserErr := deleteUserStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - deleteUserErr, - userByUUIDErr, - )) - defer g.SomeFnError( - createUserClose, - deleteUserClose, - userByUUIDClose, - db.Close, - ) - - - g.Testing("when a user doesn't exist, we get sql.ErrNoRows", func() { - _, err := userByUUID(uuid.New()) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("after creating, we can retrieve the user", func() { - newUser := newUserT{ - uuid: uuid.New(), - username: mkstring(), - displayName: mkstring(), - } - - user1, err := createUser(newUser) - g.TErrorIf(err) - - user2, err := userByUUID(newUser.uuid) - g.TErrorIf(err) - - g.TAssertEqual(user2.uuid, newUser.uuid) - g.TAssertEqual(user2.username, newUser.username) - g.TAssertEqual(user2.displayName, newUser.displayName) - g.TAssertEqual(user2.pictureID == nil, true) - - g.TAssertEqual(user2, user1) - }) - - g.Testing("we can't retrieve a user once deleted",func() { - newUser := newUserT{ - uuid: uuid.New(), - } - - _, err := createUser(newUser) - g.TErrorIf(err) - - _, err = userByUUID(newUser.uuid) - g.TErrorIf(err) - - err = deleteUser(newUser.uuid) - g.TErrorIf(err) - - _, err = userByUUID(newUser.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - userByUUIDClose(), - userByUUIDClose(), - userByUUIDClose(), - )) - }) -} - -func test_updateUserStmt() { - g.TestStart("updateUserStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - userByUUID, userByUUIDClose, userByUUIDErr := userByUUIDStmt(cfg) - updateUser, updateUserClose, updateUserErr := updateUserStmt(cfg) - deleteUser, deleteUserClose, deleteUserErr := deleteUserStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - userByUUIDErr, - updateUserErr, - deleteUserErr, - )) - defer g.SomeFnError( - createUserClose, - userByUUIDClose, - updateUserClose, - deleteUserClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - username: mkstring(), - displayName: mkstring(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - userChanges := makeUserChanges(db, prefix) - - - g.Testing("a user needs to exist to be updated", func() { - virtualUser := userT{ - id: 1234, - } - - g.TAssertEqual(updateUser(virtualUser), sql.ErrNoRows) - }) - - g.Testing("after updating, fetching gives us the newer data", func() { - newUser := newUserT{ - uuid: uuid.New(), - username: "first username", - displayName: "first display name", - } - - user1, err := createUser(newUser) - g.TErrorIf(err) - - g.TAssertEqual(user1.username, newUser.username) - g.TAssertEqual(user1.displayName, newUser.displayName) - - - user2 := user1 - user2.username = "second username" - user2.displayName = "second display name" - - err = updateUser(user2) - g.TErrorIf(err) - - g.TAssertEqual(user2.id, user1.id) - g.TAssertEqual(user2.timestamp, user1.timestamp) - g.TAssertEqual(user2.uuid, user1.uuid) - - - user3, err := userByUUID(user2.uuid) - g.TErrorIf(err) - - g.TAssertEqual(user3, user2) - g.TAssertEqual(user3.username, "second username") - g.TAssertEqual(user3.displayName, "second display name") - }) - - g.Testing("can't update a deleted user", func() { - user := create() - - err = deleteUser(user.uuid) - g.TErrorIf(err) - - err = updateUser(user) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("we can add (and remove) a picture to the user", func() { - user1 := create() - g.TAssertEqual(user1.pictureID == nil, true) - - pictureID := uuid.New() - user2 := user1 - user2.pictureID = &pictureID - err = updateUser(user2) - g.TErrorIf(err) - g.TAssertEqual(user2.pictureID == nil, false) - - user3, err := userByUUID(user1.uuid) - g.TErrorIf(err) - g.TAssertEqual(user3.pictureID == nil, false) - g.TAssertEqual(user3, user2) - - user4 := user3 - user4.pictureID = nil - err = updateUser(user4) - g.TErrorIf(err) - - user5, err := userByUUID(user1.uuid) - g.TErrorIf(err) - g.TAssertEqual(user5.pictureID == nil, true) - g.TAssertEqual(user5, user4) - }) - - g.Testing("we can't update the timestamp or uuid", func() { - user1 := create() - - user2 := user1 - user2.timestamp = user2.timestamp.Add(time.Minute * 1) - user2.uuid = uuid.New() - err := updateUser(user2) - g.TErrorIf(err) - - user3, err := userByUUID(user1.uuid) - g.TErrorIf(err) - g.TAssertEqual(user3, user1) - }) - - g.Testing("no extra writes to *_user_changes when not updated", func() { - user := create() - g.TAssertEqual(len(userChanges(user.uuid)), 3) - - err := updateUser(user) - g.TErrorIf(err) - g.TAssertEqual(len(userChanges(user.uuid)), 3) - }) - - g.Testing("new username end up in *_user_changes when updated", func() { - newUser := newUserT{ - uuid: uuid.New(), - username: "first username", - displayName: "display name", - } - - user, err := createUser(newUser) - g.TErrorIf(err) - g.TAssertEqual(len(userChanges(user.uuid)), 3) - - user.username = "second username" - g.TErrorIf(updateUser(user)) - - changes := userChanges(user.uuid)[3:] - g.TAssertEqual(len(changes), 2) - g.TAssertEqual( - []string{ - changes[0].attribute, - changes[1].attribute, - *changes[0].valueStr, - *changes[1].valueStr, - }, - []string{ - "username", - "username", - "first username", - "second username", - }, - ) - g.TAssertEqual( - []bool{ - changes[0].op, - changes[1].op, - changes[0].valueBlob == nil, - changes[0].valueBool == nil, - changes[1].valueBlob == nil, - changes[1].valueBool == nil, - }, - []bool{ - false, - true, - true, - true, - true, - true, - }, - ) - }) - - g.Testing("displayName end up in *_user_changes when updated", func() { - newUser := newUserT{ - uuid: uuid.New(), - username: "username", - displayName: "first display name", - } - - user, err := createUser(newUser) - g.TErrorIf(err) - g.TAssertEqual(len(userChanges(user.uuid)), 3) - - user.displayName = "second display name" - g.TErrorIf(updateUser(user)) - changes := userChanges(user.uuid)[3:] - g.TAssertEqual(len(changes), 2) - g.TAssertEqual( - []string{ - changes[0].attribute, - changes[1].attribute, - *changes[0].valueStr, - *changes[1].valueStr, - }, - []string{ - "display_name", - "display_name", - "first display name", - "second display name", - }, - ) - g.TAssertEqual( - []bool{ - changes[0].op, - changes[1].op, - changes[0].valueBlob == nil, - changes[0].valueBool == nil, - changes[1].valueBlob == nil, - changes[1].valueBool == nil, - }, - []bool{ - false, - true, - true, - true, - true, - true, - }, - ) - }) - - g.Testing("pictureID end up in *_user_changes when updated", func() { - newUser := newUserT{ - uuid: uuid.New(), - username: "username", - displayName: "first display name", - } - pictureID := uuid.New() - - user, err := createUser(newUser) - g.TErrorIf(err) - g.TAssertEqual(len(userChanges(user.uuid)), 3) - - user.pictureID = &pictureID - g.TErrorIf(updateUser(user)) - changes := userChanges(user.uuid)[3:] - g.TAssertEqual(len(changes), 1) - g.TAssertEqual(changes[0].attribute, "picture_uuid") - g.TAssertEqual(*changes[0].valueBlob, pictureID) - g.TAssertEqual(changes[0].op, true) - - user.pictureID = nil - g.TErrorIf(updateUser(user)) - changes = userChanges(user.uuid)[4:] - g.TAssertEqual(len(changes), 1) - g.TAssertEqual(changes[0].attribute, "picture_uuid") - g.TAssertEqual(*changes[0].valueBlob, pictureID) - g.TAssertEqual( - []bool{ - changes[0].op, - changes[0].valueStr == nil, - changes[0].valueBool == nil, - }, - []bool{ - false, - true, - true, - }, - ) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - updateUserClose(), - updateUserClose(), - updateUserClose(), - )) - }) -} - -func test_deleteUserStmt() { - g.TestStart("deleteUserStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - deleteUser, deleteUserClose, deleteUserErr := deleteUserStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - deleteUserErr, - )) - defer g.SomeFnError( - createUserClose, - deleteUserClose, - db.Close, - ) - - userChanges := makeUserChanges(db, prefix) - - - g.Testing("a user needs to exist to be deleted", func() { - err := deleteUser(uuid.New()) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("error if deleted more than once", func() { - newUser := newUserT{ - uuid: uuid.New(), - } - - _, err := createUser(newUser) - g.TErrorIf(err) - - err1 := deleteUser(newUser.uuid) - err2 := deleteUser(newUser.uuid) - g.TErrorIf(err1) - g.TAssertEqual(err2, sql.ErrNoRows) - }) - - g.Testing("deletion triggers insertion into *_user_changes", func() { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - g.TAssertEqual(len(userChanges(user.uuid)), 3) - - g.TErrorIf(deleteUser(user.uuid)) - changes := userChanges(user.uuid)[3:] - g.TAssertEqual(len(changes), 2) - g.TAssertEqual( - []string{ - changes[0].attribute, - changes[1].attribute, - }, - []string{ "deleted", "deleted" }, - ) - g.TAssertEqual( - []bool{ - *changes[0].valueBool, - changes[0].op, - changes[0].valueStr == nil, - changes[0].valueBlob == nil, - *changes[1].valueBool, - changes[1].op, - changes[1].valueStr == nil, - changes[1].valueBlob == nil, - }, - []bool{ - false, - false, - true, - true, - true, - true, - true, - true, - }, - ) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - deleteUserClose(), - deleteUserClose(), - deleteUserClose(), - )) - }) -} - -func test_addNetworkStmt() { - g.TestStart("addNetworkStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - deleteUser, deleteUserClose, deleteUserErr := deleteUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - members, membersClose, membersErr := membersStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - deleteUserErr, - addNetworkErr, - membershipErr, - membersErr, - )) - defer g.SomeFnError( - createUserClose, - deleteUserClose, - addNetworkClose, - membershipClose, - membersClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - allMembers := func(actor memberT, networkID uuid.UUID) []memberT { - rows, err := members(actor) - g.TErrorIf(err) - defer rows.Close() - - var members []memberT - err = memberEach(rows, func(member memberT) error { - members = append(members, member) - return nil - }) - g.TErrorIf(err) - - return members - } - - networkChanges := makeNetworkChanges(db, prefix) - - - g.Testing("a user can create a network", func() { - creator := create() - - newNetwork := newNetworkT{ - uuid: uuid.New(), - name: "the network name", - description: "the network description", - type_: NetworkType_Unlisted, - } - - network, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - g.TAssertEqual(network.id == 0, false) - g.TAssertEqual(network.timestamp == time.Time{}, false) - g.TAssertEqual(network.uuid, newNetwork.uuid) - g.TAssertEqual(network.name, "the network name") - g.TAssertEqual(network.description, "the network description") - g.TAssertEqual(network.type_, NetworkType_Unlisted) - }) - - g.Testing("the creator needs to exist", func() { - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Unlisted, - } - - virtualUser := userT{ - id: 1234, - } - - _, err := addNetwork(virtualUser, newNetwork, uuid.New()) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintNotNull, - ) - }) - - g.Testing("we can't add the same network twice", func() { - creator := create() - - newNetwork := newNetworkT{ - uuid: uuid.New(), - name: mkstring(), - type_: NetworkType_Unlisted, - } - - _, err1 := addNetwork(creator, newNetwork, uuid.New()) - _, err2 := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err1) - g.TAssertEqual( - err2.(golite.Error).ExtendedCode, - golite.ErrConstraintUnique, - ) - }) - - g.Testing("a user can create multiple networks", func() { - creator := create() - - newNetwork1 := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Unlisted, - } - newNetwork2 := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Unlisted, - } - - _, err1 := addNetwork(creator, newNetwork1, uuid.New()) - _, err2 := addNetwork(creator, newNetwork2, uuid.New()) - g.TErrorIf(err1) - g.TErrorIf(err2) - }) - - g.Testing("a deleted user can't create a network", func() { - creator := create() - - newNetwork1 := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Unlisted, - } - newNetwork2 := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Unlisted, - } - - _, err := addNetwork(creator, newNetwork1, uuid.New()) - g.TErrorIf(err) - - err = deleteUser(creator.uuid) - g.TErrorIf(err) - _, err = addNetwork(creator, newNetwork2, uuid.New()) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintNotNull, - ) - }) - - g.Testing("new network triggers inserts to the changes table", func() { - creator := create() - - newNetwork := newNetworkT{ - uuid: uuid.New(), - name: "the network name", - description: "the network description", - type_: NetworkType_Unlisted, - } - - _, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - changes := networkChanges(newNetwork.uuid) - g.TAssertEqual(len(changes), 4) - g.TAssertEqual( - []string{ - changes[0].attribute, - changes[1].attribute, - changes[2].attribute, - changes[3].attribute, - changes[0].value, - changes[1].value, - changes[2].value, - changes[3].value, - }, - []string{ - "name", - "description", - "type", - "deleted", - "the network name", - "the network description", - "unlisted", - "0", - }, - ) - g.TAssertEqual( - []bool{ - changes[0].op, - changes[1].op, - changes[2].op, - }, - []bool{ true, true, true }, - ) - }) - - g.Testing("the creator is automatically a member", func() { - creator := create() - memberID := uuid.New() - - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Unlisted, - } - - network, err := addNetwork(creator, newNetwork, memberID) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - members := allMembers(member, network.uuid) - g.TAssertEqual(len(members), 1) - g.TAssertEqual(members[0].uuid, memberID) - g.TAssertEqual(members[0].status, MemberStatus_Active) - }) - - g.Testing(`the creator has "creator" and "admin" roles`, func() { - creator := create() - memberID := uuid.New() - - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Unlisted, - } - - network, err := addNetwork(creator, newNetwork, memberID) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - g.TAssertEqual(member.roles, []string{"admin", "creator"}) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - addNetworkClose(), - addNetworkClose(), - addNetworkClose(), - )) - }) -} - -func test_getNetworkStmt() { - g.TestStart("getNetworkStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - deleteUser, deleteUserClose, deleteUserErr := deleteUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - getNetwork, getNetworkClose, getNetworkErr := getNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - dropMember, dropMemberClose, dropMemberErr := dropMemberStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - deleteUserErr, - addNetworkErr, - getNetworkErr, - membershipErr, - addMemberErr, - dropMemberErr, - )) - defer g.SomeFnError( - createUserClose, - deleteUserClose, - addNetworkClose, - getNetworkClose, - membershipClose, - addMemberClose, - dropMemberClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func(user userT, type_ NetworkType) (networkT, memberT) { - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: type_, - } - - network, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(user, network) - g.TErrorIf(err) - - return network, member - } - - - g.Testing("what we get is the same that was created", func() { - creator := create() - network1, _ := add(creator, NetworkType_Public) - - network2, err := getNetwork(creator, network1.uuid) - g.TErrorIf(err) - g.TAssertEqual(network2, network1) - }) - - g.Testing("a network needs to exist", func() { - _, err := getNetwork(create(), uuid.New()) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("the probing member needs to exist", func() { - creator := create() - network, _ := add(creator, NetworkType_Public) - - virtualUser := userT{ - id: 1234, - } - - _, err := getNetwork(creator, network.uuid) - g.TErrorIf(err) - - _, err = getNetwork(virtualUser, network.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("the probing user can see any public network", func() { - creator := create() - user := create() - network, _ := add(creator, NetworkType_Public) - - network1, err1 := getNetwork(creator, network.uuid) - network2, err2 := getNetwork(user, network.uuid) - g.TErrorIf(err1) - g.TErrorIf(err2) - g.TAssertEqual(network1, network) - g.TAssertEqual(network2, network) - }) - - g.Testing("the probing user sees the given unlisted network", func() { - creator := create() - user := create() - network, _ := add(creator, NetworkType_Unlisted) - - network1, err1 := getNetwork(creator, network.uuid) - network2, err2 := getNetwork(user, network.uuid) - g.TErrorIf(err1) - g.TErrorIf(err2) - g.TAssertEqual(network1, network) - g.TAssertEqual(network2, network) - }) - - g.Testing("the probing user can't see a private network", func() { - creator := create() - user := create() - network, _ := add(creator, NetworkType_Private) - - _, err1 := getNetwork(creator, network.uuid) - _, err2 := getNetwork(user, network.uuid) - g.TErrorIf(err1) - g.TAssertEqual(err2, sql.ErrNoRows) - }) - - g.Testing("the probing user must be a member to see it", func() { - creator := create() - user := create() - network, member := add(creator, NetworkType_Private) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - _, err := addMember(member, newMember) - g.TErrorIf(err) - - network1, err1 := getNetwork(creator, network.uuid) - network2, err2 := getNetwork(user, network.uuid) - g.TErrorIf(err1) - g.TErrorIf(err2) - g.TAssertEqual(network1, network) - g.TAssertEqual(network2, network) - }) - - g.Testing("we can get the network if the creator was deleted", func() { - creator := create() - user := create() - network, member := add(creator, NetworkType_Public) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - _, err := addMember(member, newMember) - g.TErrorIf(err) - - network1, err := getNetwork(creator, network.uuid) - g.TErrorIf(err) - - err = deleteUser(creator.uuid) - g.TErrorIf(err) - - network2, err := getNetwork(user, network.uuid) - g.TErrorIf(err) - g.TAssertEqual(network2, network1) - }) - - g.Testing("a deleted creator can't get a network", func() { - creator := create() - network, _ := add(creator, NetworkType_Public) - - _, err := getNetwork(creator, network.uuid) - g.TErrorIf(err) - - err = deleteUser(creator.uuid) - g.TErrorIf(err) - - _, err = getNetwork(creator, network.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("a deleted user can't get a public network", func() { - user := create() - network, _ := add(create(), NetworkType_Public) - - _, err := getNetwork(user, network.uuid) - g.TErrorIf(err) - - err = deleteUser(user.uuid) - g.TErrorIf(err) - - _, err = getNetwork(user, network.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("a deleted member can't get a private network", func() { - creator := create() - user := create() - network, member := add(creator, NetworkType_Private) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - _, err := addMember(member, newMember) - g.TErrorIf(err) - - _, err = getNetwork(user, network.uuid) - g.TErrorIf(err) - - err = deleteUser(user.uuid) - g.TErrorIf(err) - - _, err = getNetwork(user, network.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("a removed member can't get a private network", func() { - creator := create() - user := create() - network, member := add(creator, NetworkType_Private) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - _, err := getNetwork(user, network.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - - _, err = addMember(member, newMember) - g.TErrorIf(err) - - _, err = getNetwork(user, network.uuid) - g.TErrorIf(err) - - err = dropMember(member, newMember.memberID) - g.TErrorIf(err) - - _, err = getNetwork(user, network.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - getNetworkClose(), - getNetworkClose(), - getNetworkClose(), - )) - }) -} - -func test_networkEach() { - g.TestStart("networkEach()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - networks, networksClose, networksErr := networksStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - networksErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - networksClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func(user userT) uuid.UUID { - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - _, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - return newNetwork.uuid - } - - - g.Testing("callback is not called on empty set", func() { - rows, err := networks(create()) - g.TErrorIf(err) - defer rows.Close() - - networkEach(rows, func(networkT) error { - g.Unreachable() - return nil - }) - }) - - g.Testing("the callback is called once for each entry", func() { - creator := create() - networkIDs := []uuid.UUID{ - add(creator), - add(creator), - add(creator), - } - - rows, err := networks(creator) - g.TErrorIf(err) - defer rows.Close() - - var collectedIDs[]uuid.UUID - err = networkEach(rows, func(network networkT) error { - collectedIDs = append(collectedIDs, network.uuid) - return nil - }) - g.TErrorIf(err) - - g.TAssertEqual(collectedIDs, networkIDs) - }) - - g.Testing("we halt if the timestamp is ill-formatted", func() { - creator := create() - - add(creator) - add(creator) - networkID := add(creator) - add(creator) - - const tmpl = ` - UPDATE "%s_networks" - SET timestamp = %s - WHERE uuid = ?; - ` - q1 := fmt.Sprintf(tmpl, prefix, "'01/01/1970'") - _, err := db.Exec(q1, networkID[:]) - g.TErrorIf(err) - - rows, err := networks(creator) - g.TErrorIf(err) - defer rows.Close() - - n := 0 - err = networkEach(rows, func(networkT) error { - n++ - return nil - }) - - g.TAssertEqual( - err, - &time.ParseError{ - Layout: time.RFC3339Nano, - Value: "01/01/1970", - LayoutElem: "2006", - ValueElem: "01/01/1970", - Message: "", - }, - ) - g.TAssertEqual(n, 5) - - q2 := fmt.Sprintf(tmpl, prefix, g.SQLiteNow) - _, err = db.Exec(q2, networkID[:]) - g.TErrorIf(err) - }) - - g.Testing("we halt if the callback returns an error", func() { - creator := create() - myErr := errors.New("callback error early return") - - rows1, err := networks(creator) - g.TErrorIf(err) - defer rows1.Close() - - n1 := 0 - err1 := networkEach(rows1, func(networkT) error { - n1++ - if n1 == 3 { - return myErr - } - return nil - }) - - rows2, err := networks(creator) - g.TErrorIf(err) - defer rows2.Close() - - n2 := 0 - err2 := networkEach(rows2, func(networkT) error { - n2++ - return nil - }) - - g.TAssertEqual(err1, myErr) - g.TErrorIf(err2) - g.TAssertEqual(n1, 3) - g.TAssertEqual(n2, 7) - }) - - g.Testing("noop when given nil for *sql.Rows", func() { - err := networkEach(nil, func(networkT) error { - g.Unreachable() - return nil - }) - g.TErrorIf(err) - }) -} - -func test_networksStmt() { - g.TestStart("networksStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - deleteUser, deleteUserClose, deleteUserErr := deleteUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - networks, networksClose, networksErr := networksStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - deleteUserErr, - addNetworkErr, - networksErr, - )) - defer g.SomeFnError( - createUserClose, - deleteUserClose, - addNetworkClose, - networksClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func(user userT, type_ NetworkType) networkT { - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: type_, - } - - network, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - return network - } - - allNetworks := func(user userT) ([]networkT, error) { - rows, err := networks(user) - if err != nil { - return nil, err - } - defer rows.Close() - - networkList := []networkT{} - err = networkEach(rows, func(network networkT) error { - networkList = append(networkList, network) - return nil - }) - if err != nil { - return nil, err - } - - return networkList, nil - } - - - g.Testing("when there are no networks, we get 0", func() { - networks, err := allNetworks(create()) - g.TErrorIf(err) - g.TAssertEqual(len(networks), 0) - }) - - g.Testing("if we have only private networks, we also get none", func() { - creator := create() - add(creator, NetworkType_Private) - add(creator, NetworkType_Private) - add(creator, NetworkType_Private) - - networks, err := allNetworks(create()) - g.TErrorIf(err) - g.TAssertEqual(len(networks), 0) - }) - - g.Testing("when only unlisted networks, we also get none", func() { - creator := create() - add(creator, NetworkType_Unlisted) - add(creator, NetworkType_Unlisted) - add(creator, NetworkType_Unlisted) - - networks, err := allNetworks(create()) - g.TErrorIf(err) - g.TAssertEqual(len(networks), 0) - }) - - g.Testing("we can get a list of public networks", func() { - creator := create() - expected := []networkT{ - add(creator, NetworkType_Public), - add(creator, NetworkType_Public), - add(creator, NetworkType_Public), - } - - networks, err := allNetworks(create()) - g.TErrorIf(err) - g.TAssertEqual(networks, expected) - }) - - g.Testing("a member user can see their's private networks", func() { - creator1 := create() - creator2 := create() - add(creator1, NetworkType_Private) - add(creator1, NetworkType_Private) - add(creator1, NetworkType_Private) - add(creator2, NetworkType_Private) - add(creator2, NetworkType_Private) - add(creator2, NetworkType_Private) - - networks, err := allNetworks(creator2) - g.TErrorIf(err) - g.TAssertEqual(len(networks), 6) - }) - - g.Testing("a member user can see their's unlisted networks", func() { - creator1 := create() - creator2 := create() - add(creator1, NetworkType_Unlisted) - add(creator1, NetworkType_Unlisted) - add(creator1, NetworkType_Unlisted) - add(creator2, NetworkType_Unlisted) - add(creator2, NetworkType_Unlisted) - add(creator2, NetworkType_Unlisted) - - networks, err := allNetworks(creator2) - g.TErrorIf(err) - g.TAssertEqual(len(networks), 6) - }) - - g.Testing("a deleted user can't list anything", func() { - creator := create() - g.TErrorIf(deleteUser(creator.uuid)) - - _, err := allNetworks(creator) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - networksClose(), - networksClose(), - networksClose(), - )) - }) -} - -func test_setNetworkStmt() { - g.TestStart("setNetworkStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - getNetwork, getNetworkClose, getNetworkErr := getNetworkStmt(cfg) - setNetwork, setNetworkClose, setNetworkErr := setNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - addRole, addRoleClose, addRoleErr := addRoleStmt(cfg) - dropRole, dropRoleClose, dropRoleErr := dropRoleStmt(cfg) - editMember, editMemberClose, editMemberErr := editMemberStmt(cfg) - dropMember, dropMemberClose, dropMemberErr := dropMemberStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - getNetworkErr, - setNetworkErr, - membershipErr, - addMemberErr, - addRoleErr, - dropRoleErr, - editMemberErr, - dropMemberErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - getNetworkClose, - setNetworkClose, - membershipClose, - addMemberClose, - addRoleClose, - dropRoleClose, - editMemberClose, - dropMemberClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func(user userT) (networkT, memberT) { - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - memberID := uuid.New() - - network, err := addNetwork(user, newNetwork, memberID) - g.TErrorIf(err) - - member, err := membership(user, network) - g.TErrorIf(err) - - return network, member - } - - networkChanges := makeNetworkChanges(db, prefix) - - - g.Testing("creator can change the network", func() { - creator := create() - network1, member := add(creator) - - name := network1.name + "name suffix" - network1.name = name - err := setNetwork(member, network1) - g.TErrorIf(err) - - network2, err := getNetwork(creator, network1.uuid) - g.TErrorIf(err) - g.TAssertEqual(network2.name, name) - }) - - g.Testing(`"network-settings-update" can change the network`, func() { - creator := create() - admin := create() - network, creatorMember := add(creator) - newMember := newMemberT{ - userID: admin.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - adminMember, err := addMember(creatorMember, newMember) - g.TErrorIf(err) - - err = addRole( - creatorMember, - "network-settings-update", - adminMember, - ) - g.TErrorIf(err) - - name := network.name + "name suffix" - network.name = name - err = setNetwork(adminMember, network) - g.TErrorIf(err) - }) - - g.Testing(`"admin" can change the network`, func() { - creator := create() - admin := create() - network, creatorMember := add(creator) - newMember := newMemberT{ - userID: admin.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - adminMember, err := addMember(creatorMember, newMember) - g.TErrorIf(err) - - err = addRole(creatorMember, "admin", adminMember) - g.TErrorIf(err) - - name := network.name + "name suffix" - network.name = name - err = setNetwork(adminMember, network) - g.TErrorIf(err) - }) - - g.Testing("ex-member creator looses ability to change it", func() { - creator := create() - network, member := add(creator) - - err := dropMember(member, member.uuid) - g.TErrorIf(err) - - network.name = network.name + "name suffix" - err = setNetwork(member, network) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintTrigger, - ) - }) - - g.Testing("ex-admin member looses ability to change it", func() { - creator := create() - admin := create() - network, creatorMember := add(creator) - newMember := newMemberT{ - userID: admin.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - adminMember, err := addMember(creatorMember, newMember) - g.TErrorIf(err) - - err = addRole(creatorMember, "admin", adminMember) - g.TErrorIf(err) - - name := network.name + "name suffix" - network.name = name - err = setNetwork(adminMember, network) - g.TErrorIf(err) - - err = dropRole(creatorMember, "admin", adminMember) - g.TErrorIf(err) - - err = setNetwork(adminMember, network) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintTrigger, - ) - }) - - g.Testing("unauthorized users can't change the network", func() { - creator := create() - user := create() - network, creatorMember := add(creator) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - userMember, err := addMember(creatorMember, newMember) - g.TErrorIf(err) - - network.name = "member can't set the name" - err = setNetwork(userMember, network) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintTrigger, - ) - }) - - g.Testing("non members can't change the network", func() { - creator := create() - network, member := add(creator) - _, otherMember := add(creator) - - network.name = "non member can't set the name xablauzinho" - err1 := setNetwork(otherMember, network) - err2 := setNetwork(member, network) - g.TAssertEqual( - err1.(golite.Error).ExtendedCode, - golite.ErrConstraintTrigger, - ) - g.TErrorIf(err2) - }) - - g.Testing("after setting, getting gives us the newer data", func() { - creator := create() - network1, member := add(creator) - - network2 := network1 - network2.name = "first network name" - network2.description = "first network description" - network2.type_ = NetworkType_Private - - err := setNetwork(member, network2) - g.TErrorIf(err) - - network3, err := getNetwork(creator, network1.uuid) - g.TErrorIf(err) - g.TAssertEqual(network3, network2) - }) - - g.Testing("the uuid, timestamp or creator never changes", func() { - creator := create() - network1, member := add(creator) - - network2 := network1 - network2.uuid = uuid.New() - network2.timestamp = time.Time{} - - err := setNetwork(member, network2) - g.TErrorIf(err) - - network3, err := getNetwork(creator, network1.uuid) - g.TErrorIf(err) - g.TAssertEqual(network3, network1) - g.TAssertEqual(reflect.DeepEqual(network3, network2), false) - }) - - g.Testing("inactive member can't set the network", func() { - network, member := add(create()) - - member.status = "inactive" - err := editMember(member, member) - g.TErrorIf(err) - - err = setNetwork(member, network) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintTrigger, - ) - }) - - g.Testing("removed member can't set the network", func() { - network, member := add(create()) - - member.status = "removed" - err := editMember(member, member) - g.TErrorIf(err) - - err = setNetwork(member, network) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintTrigger, - ) - }) - - g.Testing("no extra writes to changes table when no updates", func() { - network, member := add(create()) - - lenBefore := len(networkChanges(network.uuid)) - - err := setNetwork(member, network) - g.TErrorIf(err) - - lenAfter := len(networkChanges(network.uuid)) - - g.TAssertEqual(lenBefore, lenAfter) - }) - - g.Testing("updates do cause writes to changes table", func() { - creator := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - name: "first name", - description: "first description", - type_: NetworkType_Public, - } - memberID := uuid.New() - - network, err := addNetwork(creator, newNetwork, memberID) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - lenBefore := len(networkChanges(network.uuid)) - - network.name = "second name" - network.description = "second description" - network.type_ = NetworkType_Unlisted - err = setNetwork(member, network) - g.TErrorIf(err) - - changes := networkChanges(network.uuid)[lenBefore:] - g.TAssertEqual(len(changes), 6) - g.TAssertEqual( - []string{ - changes[0].attribute, - changes[1].attribute, - changes[2].attribute, - changes[3].attribute, - changes[4].attribute, - changes[5].attribute, - changes[0].value, - changes[1].value, - changes[2].value, - changes[3].value, - changes[4].value, - changes[5].value, - }, - []string{ - "type", - "type", - "description", - "description", - "name", - "name", - "public", - "unlisted", - "first description", - "second description", - "first name", - "second name", - }, - ) - g.TAssertEqual( - []bool{ - changes[0].op, - changes[1].op, - changes[2].op, - changes[3].op, - changes[4].op, - changes[5].op, - }, - []bool{ - false, - true, - false, - true, - false, - true, - }, - ) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - setNetworkClose(), - setNetworkClose(), - setNetworkClose(), - )) - }) -} - -func test_nipNetworkStmt() { - g.TestStart("nipNetworkStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - getNetwork, getNetworkClose, getNetworkErr := getNetworkStmt(cfg) - networks, networksClose, networksErr := networksStmt(cfg) - setNetwork, setNetworkClose, setNetworkErr := setNetworkStmt(cfg) - nipNetwork, nipNetworkClose, nipNetworkErr := nipNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - addRole, addRoleClose, addRoleErr := addRoleStmt(cfg) - editMember, editMemberClose, editMemberErr := editMemberStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - getNetworkErr, - networksErr, - setNetworkErr, - nipNetworkErr, - membershipErr, - addMemberErr, - addRoleErr, - editMemberErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - getNetworkClose, - networksClose, - setNetworkClose, - nipNetworkClose, - membershipClose, - addMemberClose, - addRoleClose, - editMemberClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func(user userT) (networkT, memberT) { - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(user, network) - g.TErrorIf(err) - - return network, member - } - - allNetworks := func(user userT) []networkT { - rows, err := networks(user) - g.TErrorIf(err) - defer rows.Close() - - networkList := []networkT{} - err = networkEach(rows, func(network networkT) error { - networkList = append(networkList, network) - return nil - }) - g.TErrorIf(err) - - return networkList - } - - networkChanges := makeNetworkChanges(db, prefix) - - - g.Testing("can't `get` a deleted network", func() { - creator := create() - network, member := add(creator) - err := nipNetwork(member) - g.TErrorIf(err) - - _, err = getNetwork(creator, network.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("after deletion it vanishes from `networks()`", func() { - creator := create() - _, member := add(creator) - - g.TAssertEqual(len(allNetworks(creator)), 1) - - err := nipNetwork(member) - g.TErrorIf(err) - - g.TAssertEqual(len(allNetworks(creator)), 0) - }) - - g.Testing("can't `set` a deleted network", func() { - network, member := add(create()) - err := nipNetwork(member) - g.TErrorIf(err) - - err = setNetwork(member, network) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("can't delete a network more than once", func() { - _, member := add(create()) - - err1 := nipNetwork(member) - err2 := nipNetwork(member) - g.TErrorIf(err1) - g.TAssertEqual(err2, sql.ErrNoRows) - }) - - g.Testing("can't get membership of a delete network", func() { - creator := create() - network, member := add(creator) - - _, err := membership(creator, network) - g.TErrorIf(err) - - err = nipNetwork(member) - g.TErrorIf(err) - - _, err = membership(creator, network) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("an admin can delete a network", func() { - admin := create() - _, creatorMember := add(create()) - newMember := newMemberT{ - userID: admin.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - adminMember, err := addMember(creatorMember, newMember) - g.TErrorIf(err) - - err = addRole(creatorMember, "admin", adminMember) - g.TErrorIf(err) - - err = nipNetwork(adminMember) - g.TErrorIf(err) - }) - - g.Testing("a member can't delete", func() { - user := create() - _, creatorMember := add(create()) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - userMember, err := addMember(creatorMember, newMember) - g.TErrorIf(err) - - err = nipNetwork(userMember) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintTrigger, - ) - }) - - g.Testing("an inactive admin member also can't", func() { - user := create() - _, member := add(create()) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - member.status = "inactive" - err := editMember(member, member) - g.TErrorIf(err) - - _, err = addMember(member, newMember) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintNotNull, - ) - }) - - g.Testing("deletion triggers writes to the changes table", func() { - network, member := add(create()) - - changes1Len := len(networkChanges(network.uuid)) - - err := nipNetwork(member) - g.TErrorIf(err) - - changes := networkChanges(network.uuid) - g.TAssertEqual(len(changes), changes1Len + 2) - - changes = changes[changes1Len:] - g.TAssertEqual( - []string{ - changes[0].attribute, - changes[1].attribute, - changes[0].value, - changes[1].value, - }, - []string{ - "deleted", - "deleted", - "0", - "1", - }, - ) - g.TAssertEqual( - []bool{ - changes[0].op, - changes[1].op, - }, - []bool{ - false, - true, - }, - ) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - nipNetworkClose(), - nipNetworkClose(), - nipNetworkClose(), - )) - }) -} - -func test_membershipStmt() { - g.TestStart("membershipStmt") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - deleteUser, deleteUserClose, deleteUserErr := deleteUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - nipNetwork, nipNetworkClose, nipNetworkErr := nipNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - dropMember, dropMemberClose, dropMemberErr := dropMemberStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - deleteUserErr, - addNetworkErr, - nipNetworkErr, - membershipErr, - addMemberErr, - dropMemberErr, - )) - defer g.SomeFnError( - createUserClose, - deleteUserClose, - addNetworkClose, - nipNetworkClose, - membershipClose, - addMemberClose, - dropMemberClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func(user userT) networkT { - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Private, - } - - network, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - return network - } - - - g.Testing("user needs to exist", func() { - virtualUser := userT{ - id: 1234, - } - network := add(create()) - - _, err := membership(virtualUser, network) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("network needs to exist", func() { - virtualNetwork := networkT{ - id: 1234, - } - - _, err := membership(create(), virtualNetwork) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("the member contains all of its roles", func() { - creator := create() - network := add(creator) - - member, err := membership(creator, network) - g.TErrorIf(err) - g.TAssertEqual(member.roles, []string{ "admin", "creator" }) - }) - - g.Testing("a deleted user can't get their membership", func() { - creator := create() - network := add(creator) - - err := deleteUser(creator.uuid) - g.TErrorIf(err) - - _, err = membership(creator, network) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("can't get member from a deleted network", func() { - creator := create() - network := add(creator) - - member, err := membership(creator, network) - g.TErrorIf(err) - - err = nipNetwork(member) - g.TErrorIf(err) - - _, err = membership(creator, network) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("we get the same data as `addMember()`", func() { - creator := create() - user := create() - network := add(creator) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: "a username", - } - - creatorMember, err := membership(creator, network) - g.TErrorIf(err) - - member1, err := addMember(creatorMember, newMember) - g.TErrorIf(err) - - member2, err := membership(user, network) - g.TErrorIf(err) - - g.TAssertEqual(member2, member1) - }) - - g.Testing("can't get membership of ex-member", func() { - creator := create() - admin := create() - network := add(creator) - newMember := newMemberT{ - userID: admin.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - creatorMember, err := membership(creator, network) - g.TErrorIf(err) - - adminMember, err := addMember(creatorMember, newMember) - g.TErrorIf(err) - - err = dropMember(adminMember, adminMember.uuid) - g.TErrorIf(err) - - _, err = membership(admin, network) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("a non-member gets an error", func() { - network := add(create()) - - _, err := membership(create(), network) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - membershipClose(), - membershipClose(), - membershipClose(), - )) - }) -} - -func test_addMemberStmt() { - g.TestStart("addMemberStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - deleteUser, deleteUserClose, deleteUserErr := deleteUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - nipNetwork, nipNetworkClose, nipNetworkErr := nipNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - addRole, addRoleClose, addRoleErr := addRoleStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - deleteUserErr, - addNetworkErr, - nipNetworkErr, - membershipErr, - addMemberErr, - addRoleErr, - )) - defer g.SomeFnError( - createUserClose, - deleteUserClose, - addNetworkClose, - nipNetworkClose, - membershipClose, - addMemberClose, - addRoleClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func(user userT) (networkT, memberT) { - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(user, network) - g.TErrorIf(err) - - return network, member - } - - - g.Testing("the user needs to exist", func() { - _, member := add(create()) - newMember := newMemberT{ - userID: uuid.New(), - memberID: uuid.New(), - username: mkstring(), - } - - _, err := addMember(member, newMember) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintNotNull, - ) - }) - - g.Testing(`the member with role "add-member" is allowed`, func() { - user1 := create() - user2 := create() - _, member0 := add(create()) - newMember1 := newMemberT{ - userID: user1.uuid, - memberID: uuid.New(), - username: mkstring(), - } - newMember2 := newMemberT{ - userID: user2.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - member1, err := addMember(member0, newMember1) - g.TErrorIf(err) - - err = addRole(member0, "add-member", member1) - g.TErrorIf(err) - - _, err = addMember(member1, newMember2) - g.TErrorIf(err) - }) - - g.Testing(`the member with role "admin" is allowed`, func() { - user1 := create() - user2 := create() - _, member0 := add(create()) - newMember1 := newMemberT{ - userID: user1.uuid, - memberID: uuid.New(), - username: mkstring(), - } - newMember2 := newMemberT{ - userID: user2.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - member1, err := addMember(member0, newMember1) - g.TErrorIf(err) - - err = addRole(member0, "admin", member1) - g.TErrorIf(err) - - _, err = addMember(member1, newMember2) - g.TErrorIf(err) - }) - - g.Testing(`member without role "add-member" is forbidden`, func() { - user1 := create() - user2 := create() - _, member0 := add(create()) - newMember1 := newMemberT{ - userID: user1.uuid, - memberID: uuid.New(), - username: mkstring(), - } - newMember2 := newMemberT{ - userID: user2.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - member1, err := addMember(member0, newMember1) - g.TErrorIf(err) - - _, err = addMember(member1, newMember2) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintTrigger, - ) - }) - - g.Testing("can't add the same user twice", func() { - creator := create() - user := create() - _, member := add(creator) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - _, err1 := addMember(member, newMember) - _, err2 := addMember(member, newMember) - g.TErrorIf(err1) - g.TAssertEqual( - err2.(golite.Error).ExtendedCode, - golite.ErrConstraintUnique, - ) - }) - - g.Testing("the memberID must be unique", func() { - creator := create() - user1 := create() - user2 := create() - _, member := add(creator) - memberID := uuid.New() - newMember1 := newMemberT{ - userID: user1.uuid, - memberID: memberID, - username: mkstring(), - } - newMember2 := newMemberT{ - userID: user2.uuid, - memberID: memberID, - username: mkstring(), - } - - _, err1 := addMember(member, newMember1) - _, err2 := addMember(member, newMember2) - g.TErrorIf(err1) - g.TAssertEqual( - err2.(golite.Error).ExtendedCode, - golite.ErrConstraintUnique, - ) - }) - - g.Testing("the new member can't be a deleted user", func() { - creator := create() - user := create() - _, member := add(creator) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - err := deleteUser(user.uuid) - g.TErrorIf(err) - - _, err = addMember(member, newMember) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintNotNull, - ) - }) - - g.Testing("can't add to a deleted network", func() { - creator := create() - user := create() - _, member := add(creator) - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - err := nipNetwork(member) - g.TErrorIf(err) - - _, err = addMember(member, newMember) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintNotNull, - ) - }) - - g.Testing("the same user can be a member of distinct networks", func() { - // FIXME - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - addMemberClose(), - addMemberClose(), - addMemberClose(), - )) - }) -} - -func test_addRoleStmt() { - g.TestStart("addRoleStmt") - - // FIXME -} - -func test_dropRoleStmt() { - g.TestStart("dropRoleStmt") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - dropRole, dropRoleClose, dropRoleErr := dropRoleStmt(cfg) - showMember, showMemberClose, showMemberErr := showMemberStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - dropRoleErr, - showMemberErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - dropRoleClose, - showMemberClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func(user userT) (networkT, memberT) { - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(user, network) - g.TErrorIf(err) - - return network, member - } - - - g.Testing("acting member must exist", func() { - _, member := add(create()) - virtualMember := memberT{} - - err := dropRole(virtualMember, "a-role", member) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("target member must also exist", func() { - _, member := add(create()) - virtualMember := memberT{} - - err := dropRole(member, "a-role", virtualMember) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("noop if member doesn't have the role", func() { - return // FIXME - _, member := add(create()) - g.TAssertEqual(member.roles, []string{ "admin", "creator" }) - - err := dropRole(member, "does-not-exist", member) - g.TErrorIf(err) - - member, err = showMember(member, member.uuid) - g.TErrorIf(err) - g.TAssertEqual(member.roles, []string{ "admin", "creator" }) - }) - - g.Testing("role is removed when exists", func() { - _, member := add(create()) - g.TAssertEqual(member.roles, []string{ "admin", "creator" }) - - err := dropRole(member, "admin", member) - g.TErrorIf(err) - - member, err = showMember(member, member.uuid) - g.TErrorIf(err) - g.TAssertEqual(member.roles, []string{ "creator" }) - }) - - g.Testing("member can remove all roles from themselves", func() { - // FIXME - }) - - g.Testing(`member with "role-write" can drop others roles`, func() { - // FIXME - }) - - g.Testing(`member without "role-write" can't`, func() { - // FIXME - }) - - g.Testing("does not affect other members from other networks", func() { - // FIXME - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - dropRoleClose(), - dropRoleClose(), - dropRoleClose(), - )) - }) -} - -func test_showMemberStmt() { - g.TestStart("showMemberStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - showMember, showMemberClose, showMemberErr := showMemberStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - showMemberErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - showMemberClose, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func(user userT) (networkT, memberT) { - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(user, network) - g.TErrorIf(err) - - return network, member - } - - - g.Testing("target member must exist", func() { - _, member := add(create()) - _, err := showMember(member, uuid.New()) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("acting member must exist", func() { - virtualMember := memberT{ - id: 1234, - } - - _, member := add(create()) - _, err := showMember(virtualMember, member.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("target user must belong to the same network", func() { - _, member1 := add(create()) - _, member2 := add(create()) - - _, err := showMember(member1, member2.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("we get the full member", func() { - _, member1 := add(create()) - member2, err := showMember(member1, member1.uuid) - g.TErrorIf(err) - - g.TAssertEqual(member2, member1) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - showMemberClose(), - showMemberClose(), - showMemberClose(), - )) - }) -} - -func test_memberEach() { - g.TestStart("memberEach()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - members, membersClose, membersErr := membersStmt(cfg) - editMember, editMemberClose, editMemberErr := editMemberStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - addMemberErr, - membersErr, - editMemberErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - addMemberClose, - membersClose, - editMemberClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - creator := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - return member - } - - addM := func(actor memberT, status MemberStatus) memberT { - user := create() - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - member, err := addMember(actor, newMember) - g.TErrorIf(err) - - member.status = status - err = editMember(actor, member) - g.TErrorIf(err) - - return member - } - - - g.Testing("callback is called once for new network", func() { - member := add() - - rows, err := members(member) - g.TErrorIf(err) - defer rows.Close() - - memberIDs := []uuid.UUID{} - err = memberEach(rows, func(member memberT) error { - memberIDs = append(memberIDs, member.uuid) - return nil - }) - - g.TAssertEqual(len(memberIDs), 1) - g.TAssertEqual(memberIDs[0], member.uuid) - }) - - g.Testing("we halt if the callback returns an error", func() { - myErr := errors.New("callback custom error") - member := add() - expectedIDs := []uuid.UUID{ - member.uuid, - addM(member, MemberStatus_Active).uuid, - addM(member, MemberStatus_Active).uuid, - addM(member, MemberStatus_Active).uuid, - addM(member, MemberStatus_Active).uuid, - addM(member, MemberStatus_Active).uuid, - } - - rows, err := members(member) - g.TErrorIf(err) - defer rows.Close() - - memberIDs := []uuid.UUID{} - err = memberEach(rows, func(member memberT) error { - if len(memberIDs) == 3 { - return myErr - } - - memberIDs = append(memberIDs, member.uuid) - return nil - }) - g.TAssertEqual(err, myErr) - g.TAssertEqual(len(memberIDs), 3) - g.TAssertEqual(memberIDs, expectedIDs[0:3]) - }) - - g.Testing("noop when given nil for *sql.Rows", func() { - err := memberEach(nil, func(memberT) error { - g.Unreachable() - return nil - }) - g.TErrorIf(err) - }) -} - -func test_membersStmt() { - g.TestStart("membersStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - nipNetwork, nipNetworkClose, nipNetworkErr := nipNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - members, membersClose, membersErr := membersStmt(cfg) - editMember, editMemberClose, editMemberErr := editMemberStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - nipNetworkErr, - membershipErr, - addMemberErr, - membersErr, - editMemberErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - nipNetworkClose, - membershipClose, - addMemberClose, - membersClose, - editMemberClose, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - creator := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - member.roles = nil - - return member - } - - addM := func(actor memberT, status MemberStatus) memberT { - user := create() - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - member, err := addMember(actor, newMember) - g.TErrorIf(err) - - member.status = status - err = editMember(actor, member) - g.TErrorIf(err) - - member.roles = nil - - return member - } - - allMembers := func(member memberT) []memberT { - rows, err := members(member) - g.TErrorIf(err) - defer rows.Close() - - memberList := []memberT{} - err = memberEach(rows, func(member memberT) error { - memberList = append(memberList, member) - return nil - }) - g.TErrorIf(err) - - return memberList - } - - - // FIXME: members from other networks do not show up - g.Testing("inactive and removed members aren't listed", func() { - member := add() - expected := []memberT{ - member, - addM(member, MemberStatus_Active), - addM(member, MemberStatus_Inactive), - addM(member, MemberStatus_Inactive), - addM(member, MemberStatus_Removed), - addM(member, MemberStatus_Removed), - } - - given := allMembers(member) - - g.TAssertEqual(len(given), 2) - g.TAssertEqual(given[0], expected[0]) - g.TAssertEqual(given, expected[0:2]) - }) - - g.Testing("a deleted network has 0 members", func() { - member := add() - - g.TAssertEqual(len(allMembers(member)), 1) - - err = nipNetwork(member) - g.TErrorIf(err) - - g.TAssertEqual(len(allMembers(member)), 0) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - membersClose(), - membersClose(), - membersClose(), - )) - }) -} - -func test_editMemberStmt() { - g.TestStart("editMemberStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - nipNetwork, nipNetworkClose, nipNetworkErr := nipNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - showMember, showMemberClose, showMemberErr := showMemberStmt(cfg) - editMember, editMemberClose, editMemberErr := editMemberStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - nipNetworkErr, - membershipErr, - showMemberErr, - editMemberErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - nipNetworkClose, - membershipClose, - showMemberClose, - editMemberClose, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - user := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(user, network) - g.TErrorIf(err) - - return member - } - - // FIXME - // memberChanges := makeMemberChanges(db, prefix) - - // FIXME - - if nipNetwork != nil && showMember != nil && editMember != nil {} - if add != nil {} - - g.Testing("edit triggers writes to changes table", func() { - // FIXME - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - editMemberClose(), - editMemberClose(), - editMemberClose(), - )) - }) -} - -func test_dropMemberStmt() { - // FIXME - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - // FIXME - )) - }) -} - -func test_addChannelStmt() { - g.TestStart("addChannelStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addChannel, addChannelClose, addChannelErr := addChannelStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - addChannelErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - addChannelClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - creator := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - return member - } - - channelChanges := makeChannelChanges(db, prefix) - - - g.Testing("the new channel has the data it was given", func() { - member := add() - publicName := "a-name" - newChannel := newChannelT{ - uuid: uuid.New(), - publicName: &publicName, - label: "a-label", - description: "the description", - virtual: false, - } - - channel, err := addChannel(member, newChannel) - g.TErrorIf(err) - - g.TAssertEqual(channel.id == 0, false) - g.TAssertEqual(channel.timestamp == time.Time{}, false) - g.TAssertEqual(channel.uuid, newChannel.uuid) - g.TAssertEqual(*channel.publicName, "a-name") - g.TAssertEqual(channel.label, "a-label") - g.TAssertEqual(channel.description, "the description") - g.TAssertEqual(channel.virtual, false) - }) - - g.Testing("new channel causes inserts to the changes table", func() { - member := add() - publicName := "another name" - newChannel := newChannelT{ - uuid: uuid.New(), - publicName: &publicName, - label: "the label", - description: "the description", - virtual: false, - } - - channel, err := addChannel(member, newChannel) - g.TErrorIf(err) - - changes := channelChanges(channel.id) - g.TAssertEqual(len(changes), 4) - g.TAssertEqual( - []string{ - changes[0].attribute, - changes[1].attribute, - changes[2].attribute, - changes[3].attribute, - changes[0].value, - changes[1].value, - changes[2].value, - changes[3].value, - }, - []string{ - "public_name", - "label", - "description", - "virtual", - "another name", - "the label", - "the description", - "false", - }, - ) - g.TAssertEqual( - []bool{ - changes[0].op, - changes[1].op, - changes[2].op, - changes[3].op, - }, - []bool{ - true, - true, - true, - true, - }, - ) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - addChannelClose(), - addChannelClose(), - addChannelClose(), - )) - }) -} - -func test_channelEach() { - g.TestStart("channelEach()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addChannel, addChannelClose, addChannelErr := addChannelStmt(cfg) - channels, channelsClose, channelsErr := channelsStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - addChannelErr, - channelsErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - addChannelClose, - channelsClose, - db.Close, - ) - - add := func() memberT { - newUser := newUserT{ - uuid: uuid.New(), - } - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - network, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(user, network) - g.TErrorIf(err) - - return member - } - - addC := func(member memberT) channelT { - publicName := mkstring() - newChannel := newChannelT{ - uuid: uuid.New(), - publicName: &publicName, - label: mkstring(), - description: mkstring(), - virtual: true, - } - - channel, err := addChannel(member, newChannel) - g.TErrorIf(err) - - return channel - } - - - g.Testing("callback is not called on empty set", func() { - rows, err := channels(add()) - g.TErrorIf(err) - defer rows.Close() - - err = channelEach(rows, func(channelT) error { - g.Unreachable() - return nil - }) - g.TErrorIf(err) - }) - - g.Testing("the callback is called once for each entry", func() { - member := add() - expected := []channelT{ - addC(member), - addC(member), - addC(member), - } - - rows, err := channels(member) - g.TErrorIf(err) - defer rows.Close() - - channels := []channelT{} - err = channelEach(rows, func(channel channelT) error { - channels = append(channels, channel) - return nil - }) - g.TErrorIf(err) - - g.TAssertEqual(channels, expected) - }) - - g.Testing("we halt if the callback returns an error", func() { - member := add() - myErr := errors.New("callback error early return") - addC(member) - addC(member) - addC(member) - addC(member) - addC(member) - - rows1, err1 := channels(member) - rows2, err2 := channels(member) - g.TErrorIf(err1) - g.TErrorIf(err2) - defer rows1.Close() - defer rows2.Close() - - n1 := 0 - n2 := 0 - - err1 = channelEach(rows1, func(channelT) error { - n1++ - if n1 == 3 { - return myErr - } - return nil - }) - - err2 = channelEach(rows2, func(channelT) error { - n2++ - return nil - }) - - g.TAssertEqual(err1, myErr) - g.TErrorIf(err2) - g.TAssertEqual(n1, 3) - g.TAssertEqual(n2, 5) - }) - - g.Testing("noop when given nil for *sql.Rows", func() { - err := channelEach(nil, func(channelT) error { - g.Unreachable() - return nil - }) - g.TErrorIf(err) - }) -} - -func test_channelsStmt() { - g.TestStart("channelsStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - addChannel, addChannelClose, addChannelErr := addChannelStmt(cfg) - channels, channelsClose, channelsErr := channelsStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - addMemberErr, - addChannelErr, - channelsErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - addMemberClose, - addChannelClose, - channelsClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - user := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(user, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(user, network) - g.TErrorIf(err) - - return member - } - - addM := func(member memberT) memberT { - user := create() - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - addedMember, err := addMember(member, newMember) - g.TErrorIf(err) - - return addedMember - } - - addC := func( - member memberT, - publicName *string, - virtual bool, - ) channelT { - newChannel := newChannelT{ - uuid: uuid.New(), - publicName: publicName, - label: mkstring(), - description: mkstring(), - virtual: virtual, - } - - channel, err := addChannel(member, newChannel) - g.TErrorIf(err) - - return channel - } - - allChannels := func(member memberT) ([]channelT, error) { - rows, err := channels(member) - if err != nil { - return nil, err - } - defer rows.Close() - - channelList := []channelT{} - err = channelEach(rows, func(channel channelT) error { - channelList = append(channelList, channel) - return nil - }) - if err != nil { - return nil, err - } - - return channelList, nil - } - - - g.Testing("when there are no channels, we get 0", func() { - channels, err := allChannels(add()) - g.TErrorIf(err) - g.TAssertEqual(len(channels), 0) - }) - - g.Testing("when only private channels, owner gets all", func() { - member := add() - addC(member, nil, true) - addC(member, nil, true) - addC(member, nil, false) - addC(member, nil, false) - - channels, err := allChannels(member) - g.TErrorIf(err) - g.TAssertEqual(len(channels), 4) - }) - - g.Testing("when only private channels, others get none", func() { - member1 := add() - member2 := addM(member1) - addC(member1, nil, true) - addC(member1, nil, true) - addC(member1, nil, false) - addC(member1, nil, false) - - channels1, err1 := allChannels(member1) - channels2, err2 := allChannels(member2) - g.TErrorIf(err1) - g.TErrorIf(err2) - g.TAssertEqual(len(channels1), 4) - g.TAssertEqual(len(channels2), 0) - }) - - g.Testing("private channels we are a member of show up", func() { - member1 := add() - member2 := addM(member1) - name1 := "channel-name-1" - name2 := "channel-name-2" - addC(member1, nil, true) - addC(member2, nil, true) - addC(member1, nil, false) - addC(member2, nil, false) - addC(member1, &name1, true) - addC(member2, &name2, false) - - channels, err := allChannels(member1) - g.TErrorIf(err) - g.TAssertEqual(len(channels), 4) - }) - - g.Testing("we never list channels from other networks", func() { - member := add() - name1 := "a name 1" - name2 := "a name 2" - addC(member, nil, true) - addC(member, nil, false) - addC(member, &name1, true) - addC(member, &name2, false) - - channels, err := allChannels(add()) - g.TErrorIf(err) - g.TAssertEqual(len(channels), 0) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - channelsClose(), - channelsClose(), - channelsClose(), - )) - }) -} - -func test_setChannelStmt() { - g.TestStart("setChannelStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - addChannel, addChannelClose, addChannelErr := addChannelStmt(cfg) - setChannel, setChannelClose, setChannelErr := setChannelStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - addMemberErr, - addChannelErr, - setChannelErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - addMemberClose, - addChannelClose, - setChannelClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - creator := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - return member - } - - addM := func(actor memberT) memberT { - user := create() - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - member, err := addMember(actor, newMember) - g.TErrorIf(err) - - return member - } - - addC := func(actor memberT, description string) channelT { - publicName := mkstring() - newChannel := newChannelT{ - uuid: uuid.New(), - publicName: &publicName, - label: mkstring(), - description: description, - virtual: false, - } - - channel, err := addChannel(actor, newChannel) - g.TErrorIf(err) - - return channel - } - - - g.Testing("acting member must exist", func() { - channel := addC(add(), "description") - virtualMember := memberT{ - id: 1234, - } - - err := setChannel(virtualMember, channel) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("target channel must exist", func() { - virtualChannel := channelT{ - id: 1234, - } - - err := setChannel(add(), virtualChannel) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("member can't set topic of other network", func() { - member := add() - otherMember := add() - channel := addC(member, "desc") - - err := setChannel(otherMember, channel) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("actor must participate in the channel to set topic", func() { - member1 := add() - member2 := addM(member1) - channel := addC(member1, "desc") - - err := setChannel(member2, channel) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("participant can edit", func() { - member := add() - channel := addC(member, "first description") - - channel.description = "second description" - err := setChannel(member, channel) - g.TErrorIf(err) - }) - - // we can make a private channel public - // we can make a public channel private - - g.Testing("update adds entries to *_changes table", func() { - // FIXME - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - setChannelClose(), - setChannelClose(), - setChannelClose(), - )) - }) -} - -func test_endChannelStmt() { - // FIXME - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - // FIXME - )) - }) -} - -func test_joinStmt() { - g.TestStart("joinStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - addChannel, addChannelClose, addChannelErr := addChannelStmt(cfg) - setChannel, setChannelClose, setChannelErr := setChannelStmt(cfg) - join, joinClose, joinErr := joinStmt(cfg) - part, partClose, partErr := partStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - addMemberErr, - addChannelErr, - setChannelErr, - joinErr, - partErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - addMemberClose, - addChannelClose, - setChannelClose, - joinClose, - partClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - creator := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - return member - } - - addM := func(actor memberT) memberT { - user := create() - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - member, err := addMember(actor, newMember) - g.TErrorIf(err) - - return member - } - - addC := func(actor memberT, publicName *string, virtual bool) channelT { - newChannel := newChannelT{ - uuid: uuid.New(), - publicName: publicName, - label: mkstring(), - description: mkstring(), - virtual: virtual, - } - - channel, err := addChannel(actor, newChannel) - g.TErrorIf(err) - - return channel - } - - - g.Testing("acting member must exist", func() { - name := "name" - channel := addC(add(), &name, false) - virtualMember := memberT{ - id: 1234, - } - - err := join(virtualMember, channel.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("target channel must exist", func() { - err := join(add(), uuid.New()) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("can't join a private channel", func() { - creator := add() - member := addM(creator) - channel := addC(creator, nil, false) - - err := join(member, channel.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("can't join a virtual channel", func() { - creator := add() - member := addM(creator) - channel := addC(creator, nil, true) - - err := join(member, channel.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("can't join channel in different network", func() { - name := "name" - member1 := add() - member2 := add() - channel := addC(member1, &name, false) - - err := join(member2, channel.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("creator can't rejoin after leaving private channel", func() { - creator := add() - channel := addC(creator, nil, false) - - err := part(creator, channel) - g.TErrorIf(err) - - err = join(creator, channel.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("can't join public channel one already participates", func() { - name := "name" - creator := add() - channel := addC(creator, &name, false) - - err := join(creator, channel.uuid) - g.TAssertEqual( - err.(golite.Error).ExtendedCode, - golite.ErrConstraintUnique, - ) - }) - - g.Testing("neither a private channel", func() { - creator := add() - channel := addC(creator, nil, false) - - err := join(creator, channel.uuid) - return // FIXME - g.TAssertEqual( - err, - golite.ErrConstraintUnique, - ) - }) - - g.Testing("after made public, one can join a channel", func() { - name := "name" - creator := add() - member := addM(creator) - channel := addC(creator, nil, false) - - err := join(member, channel.uuid) - g.TAssertEqual(err, sql.ErrNoRows) - - channel.publicName = &name - err = setChannel(creator, channel) - g.TErrorIf(err) - - err = join(member, channel.uuid) - g.TErrorIf(err) - }) - - // FIXME - // creates "user-join" event in feed - // joining adds rows to *_changes table - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - joinClose(), - joinClose(), - joinClose(), - )) - }) -} - -func test_partStmt() { - g.TestStart("partStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addMember, addMemberClose, addMemberErr := addMemberStmt(cfg) - addChannel, addChannelClose, addChannelErr := addChannelStmt(cfg) - part, partClose, partErr := partStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - addMemberErr, - addChannelErr, - partErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - addMemberClose, - addChannelClose, - partClose, - db.Close, - ) - - create := func() userT{ - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - creator := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - return member - } - - addM := func(actor memberT) memberT { - user := create() - newMember := newMemberT{ - userID: user.uuid, - memberID: uuid.New(), - username: mkstring(), - } - - member, err := addMember(actor, newMember) - g.TErrorIf(err) - - return member - } - - addC := func(actor memberT, virtual bool) channelT { - publicName := "public name" - newChannel := newChannelT{ - uuid: uuid.New(), - publicName: &publicName, - label: mkstring(), - description: mkstring(), - virtual: virtual, - } - - channel, err := addChannel(actor, newChannel) - g.TErrorIf(err) - - return channel - } - - - g.Testing("acting member must exist", func() { - channel := addC(add(), false) - virtualMember := memberT{ - id: 1234, - } - - err := part(virtualMember, channel) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("target channel must exist", func() { - virtualChannel := channelT{ - id: 1234, - } - - err := part(add(), virtualChannel) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("must be a member to part", func() { - creator := add() - member := addM(creator) - channel := addC(creator, false) - - err := part(member, channel) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("can't part from a virtual channel", func() { - member := add() - channel := addC(member, true) - - err := part(member, channel) - g.TAssertEqual(err, sql.ErrNoRows) - }) - - g.Testing("can part from non-virtual channel", func() { - member := add() - channel := addC(member, false) - - err := part(member, channel) - g.TErrorIf(err) - }) - - - // FIXME - // parting adds rows to *_changes table - // after parting, vanishes from member channel list - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - partClose(), - partClose(), - partClose(), - )) - }) -} - -func test_nameEach() { - // FIXME -} - -func test_namesStmt() { - // FIXME - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - // FIXME - )) - }) -} - -func test_addEventStmt() { - g.TestStart("addEventStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addChannel, addChannelClose, addChannelErr := addChannelStmt(cfg) - addEvent, addEventClose, addEventErr := addEventStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - addChannelErr, - addEventErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - addChannelClose, - addEventClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - creator := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - return member - } - - addC := func(actor memberT) channelT { - publicName := mkstring() - newChannel := newChannelT{ - uuid: uuid.New(), - publicName: &publicName, - label: mkstring(), - description: mkstring(), - virtual: false, - } - - channel, err := addChannel(actor, newChannel) - g.TErrorIf(err) - - return channel - } - - - g.Testing("we can create new events", func() { - creator := add() - channel := addC(creator) - newEvent := newEventT{ - eventID: uuid.New(), - channelID: channel.uuid, - source: sourceT{ - uuid: creator.uuid, - type_: SourceType_Logon, - }, - type_: EventType_UserMessage, - payload: "the-payload", - } - - event, err := addEvent(newEvent) - g.TErrorIf(err) - - g.TAssertEqual(event.id == 0, false) - g.TAssertEqual(event.timestamp == time.Time{}, false) - g.TAssertEqual(event.uuid, newEvent.eventID) - g.TAssertEqual(event.channelID, newEvent.channelID) - g.TAssertEqual(event.source, newEvent.source) - g.TAssertEqual(event.type_, EventType_UserMessage) - g.TAssertEqual(event.payload, "the-payload") - g.TAssertEqual(event.metadata == nil, true) - }) - - g.Testing("eventID's must be unique", func() { - creator := add() - channel := addC(creator) - newEvent := newEventT{ - eventID: uuid.New(), - channelID: channel.uuid, - source: sourceT{ - uuid: creator.uuid, - type_: SourceType_Logon, - }, - type_: EventType_UserMessage, - payload: "the payload", - } - - _, err1 := addEvent(newEvent) - _, err2 := addEvent(newEvent) - g.TErrorIf(err1) - g.TAssertEqual( - err2.(golite.Error).ExtendedCode, - golite.ErrConstraintUnique, - ) - }) - - g.Testing("multiple messages can have the same source", func() { - source := sourceT{ - uuid: uuid.New(), - type_: SourceType_Logon, - } - - newEvent1 := newEventT{ - eventID: uuid.New(), - channelID: addC(add()).uuid, - source: source, - type_: EventType_UserMessage, - } - newEvent2 := newEventT{ - eventID: uuid.New(), - channelID: addC(add()).uuid, - source: source, - type_: EventType_UserMessage, - } - - _, err1 := addEvent(newEvent1) - _, err2 := addEvent(newEvent2) - g.TErrorIf(err1) - g.TErrorIf(err2) - }) - - g.Testing("messages can be duplicated: same type and payload", func() { - type_ := EventType_UserMessage - payload := "a-payload" - - newEvent1 := newEventT{ - eventID: uuid.New(), - channelID: addC(add()).uuid, - source: sourceT{ - uuid: uuid.New(), - type_: SourceType_Logon, - }, - type_: type_, - payload: payload, - } - newEvent2 := newEventT{ - eventID: uuid.New(), - channelID: addC(add()).uuid, - source: sourceT{ - uuid: uuid.New(), - type_: SourceType_Logon, - }, - type_: type_, - payload: payload, - } - - _, err1 := addEvent(newEvent1) - _, err2 := addEvent(newEvent2) - g.TErrorIf(err1) - g.TErrorIf(err2) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - addEventClose(), - addEventClose(), - addEventClose(), - )) - }) -} - -func test_eventEach() { - g.TestStart("eventEach()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addChannel, addChannelClose, addChannelErr := addChannelStmt(cfg) - addEvent, addEventClose, addEventErr := addEventStmt(cfg) - allAfter, allAfterClose, allAfterErr := allAfterStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - addChannelErr, - addEventErr, - allAfterErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - addChannelClose, - addEventClose, - allAfterClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - creator := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - return member - } - - addC := func(actor memberT) channelT { - publicName := mkstring() - newChannel := newChannelT{ - uuid: uuid.New(), - publicName: &publicName, - label: mkstring(), - description: mkstring(), - virtual: false, - } - - channel, err := addChannel(actor, newChannel) - g.TErrorIf(err) - - return channel - } - - eventCount := 0 - addE := func(channelID uuid.UUID) eventT { - eventCount++ - newEvent := newEventT{ - // FIXME: missing eventID? - channelID: channelID, - source: sourceT{ - uuid: uuid.New(), - type_: SourceType_Logon, - }, - type_: EventType_UserMessage, - payload: fmt.Sprintf("event %s", eventCount), - } - - event, err := addEvent(newEvent) - g.TErrorIf(err) - - return event - } - - - g.Testing("callback is not called when there is no message", func() { - eventID := uuid.New() - member := add() - rows, err := allAfter(member, eventID) - g.TErrorIf(err) - defer rows.Close() - - err = eventEach(rows, func(eventT) error { - g.Unreachable() - return nil - }) - g.TErrorIf(err) - }) - - g.Testing("the callback is called once for each entry", func() { - return // FIXME - - eventID := uuid.New() - member := add() - channel := addC(member) - expected := []eventT{ - addE(channel.uuid), - addE(channel.uuid), - addE(channel.uuid), - } - - rows, err := allAfter(member, eventID) - g.TErrorIf(err) - defer rows.Close() - - events := []eventT{} - err = eventEach(rows, func(event eventT) error { - events = append(events, event) - return nil - }) - g.TErrorIf(err) - - g.TAssertEqual(events, expected) - }) - - g.Testing("it halts if a callback returns an error", func() { - return // FIXME - - eventID := uuid.New() - member := add() - channel := addC(member) - myErr := errors.New("callback error early return") - addE(channel.uuid) - addE(channel.uuid) - addE(channel.uuid) - addE(channel.uuid) - addE(channel.uuid) - - rows1, err1 := allAfter(member, eventID) - rows2, err2 := allAfter(member, eventID) - g.TErrorIf(err1) - g.TErrorIf(err2) - defer rows1.Close() - defer rows2.Close() - - n1 := 0 - n2 := 0 - - err1 = eventEach(rows1, func(eventT) error { - n1++ - if n1 == 3 { - return myErr - } - return nil - }) - - err2 = eventEach(rows2, func(eventT) error { - n2++ - return nil - }) - - g.TAssertEqual(n1, myErr) - g.TErrorIf(err2) - g.TAssertEqual(n1, 3) - g.TAssertEqual(n2, 5) - }) - - g.Testing("noop when given a nil for *sql.Rows", func() { - err := eventEach(nil, func(eventT) error { - g.Unreachable() - return nil - }) - g.TErrorIf(err) - }) -} - -func test_allAfterStmt() { - g.TestStart("allAfterStmt()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - db, err := sql.Open(golite.DriverName, dbpath) - g.TErrorIf(err) - g.TErrorIf(createTables(db, prefix)) - - cfg := dbconfigT{ - shared: db, - dbpath: dbpath, - prefix: prefix, - } - createUser, createUserClose, createUserErr := createUserStmt(cfg) - addNetwork, addNetworkClose, addNetworkErr := addNetworkStmt(cfg) - membership, membershipClose, membershipErr := membershipStmt(cfg) - addChannel, addChannelClose, addChannelErr := addChannelStmt(cfg) - addEvent, addEventClose, addEventErr := addEventStmt(cfg) - allAfter, allAfterClose, allAfterErr := allAfterStmt(cfg) - g.TErrorIf(g.SomeError( - createUserErr, - addNetworkErr, - membershipErr, - addChannelErr, - addEventErr, - allAfterErr, - )) - defer g.SomeFnError( - createUserClose, - addNetworkClose, - membershipClose, - addChannelClose, - addEventClose, - allAfterClose, - db.Close, - ) - - create := func() userT { - newUser := newUserT{ - uuid: uuid.New(), - } - - user, err := createUser(newUser) - g.TErrorIf(err) - - return user - } - - add := func() memberT { - creator := create() - newNetwork := newNetworkT{ - uuid: uuid.New(), - type_: NetworkType_Public, - } - - network, err := addNetwork(creator, newNetwork, uuid.New()) - g.TErrorIf(err) - - member, err := membership(creator, network) - g.TErrorIf(err) - - return member - } - - addC := func(actor memberT) channelT { - publicName := mkstring() - newChannel := newChannelT{ - uuid: uuid.New(), - publicName: &publicName, - label: mkstring(), - description: mkstring(), - virtual: false, - } - - channel, err := addChannel(actor, newChannel) - g.TErrorIf(err) - - return channel - } - - eventCount := 0 - addE := func(channelID uuid.UUID) eventT { - eventCount++ - newEvent := newEventT{ - eventID: uuid.New(), - channelID: channelID, - source: sourceT{ - uuid: uuid.New(), - type_: SourceType_Logon, - }, - type_: EventType_UserMessage, - payload: fmt.Sprintf("event %s", eventCount), - } - - event, err := addEvent(newEvent) - g.TErrorIf(err) - - return event - } - - // FIXME - allEvents := func(eventID uuid.UUID) []eventT { - rows, err := allAfter(memberT{}, eventID) - g.TErrorIf(err) - defer rows.Close() - - events := []eventT{} - err = eventEach(rows, func(event eventT) error { - events = append(events, event) - return nil - }) - g.TErrorIf(err) - - return events - } - - - g.Testing("after joining the channel, there are no events", func() { - return // FIXME - - eventID := uuid.New() - member := add() - channel := addC(member) - - expected := []eventT{ - // FIXME: missing "user-join" event - addE(channel.uuid), - addE(channel.uuid), - addE(channel.uuid), - } - - given := allEvents(eventID) - g.TAssertEqual(given, expected) - }) - - g.Testing("we don't get events from other channels", func() { - return // FIXME - - eventID := uuid.New() - member := add() - channel1 := addC(member) - channel2 := addC(member) - - events := []eventT{ - addE(channel1.uuid), - addE(channel1.uuid), - addE(channel2.uuid), - addE(channel2.uuid), - addE(channel1.uuid), - addE(channel1.uuid), - } - - given := allEvents(eventID) - g.TAssertEqual(given, events[2:4]) - }) - - g.Testing("as we change the reference point, the list changes", func() { - return // FIXME - - eventID := uuid.New() - member := add() - channel := addC(member) - - events := []eventT{ - addE(channel.uuid), - addE(channel.uuid), - addE(channel.uuid), - addE(channel.uuid), - addE(channel.uuid), - } - - g.TAssertEqual(len(allEvents(eventID)), 5) - g.TAssertEqual(len(allEvents(events[0].uuid)), 4) - g.TAssertEqual(len(allEvents(events[1].uuid)), 3) - g.TAssertEqual(len(allEvents(events[2].uuid)), 2) - g.TAssertEqual(len(allEvents(events[3].uuid)), 1) - g.TAssertEqual(len(allEvents(events[4].uuid)), 0) - }) - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - allAfterClose(), - allAfterClose(), - allAfterClose(), - )) - }) -} - -func test_logMessageStmt() { - g.TestStart("logMessageStmt()") - - // FIXME - - g.Testing("no error if closed more than once", func() { - g.TErrorIf(g.SomeError( - // FIXME - )) - }) -} - -func test_initDB() { - g.TestStart("initDB()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - queries, err := initDB(dbpath, prefix) - g.TErrorIf(err) - defer queries.close() - - - g.Testing("we can perform all wrapped operations", func() { - // FIXME - }) -} - -func test_queriesTclose() { - g.TestStart("queriesT.close()") - - const ( - dbpath = golite.InMemory - prefix = defaultPrefix - ) - - queries, err := initDB(dbpath, prefix) - g.TErrorIf(err) - defer queries.close() - - - g.Testing("closing more than once does not error", func() { - g.TErrorIf(g.SomeError( - queries.close(), - queries.close(), - queries.close(), - )) - }) -} - -func test_splitOnCRLF() { - g.TestStart("splitOnCRLF()") - - g.Testing("we need an ending \\r\\n to get output", func() { - type tableT struct{ - input string - expected []string - } - table := []tableT{ - { - "", - nil, - }, - { - "\r\n", - []string{ "" }, - }, - { - "abc\r\n", - []string{ "abc" }, - }, - { - "abc\r\n ", - []string{ "abc" }, - }, - { - "abc\r\n This gets ignored.\n", - []string{ "abc" }, - }, - { - "abc\r\n \r\n", - []string{ "abc", " " }, - }, - { - " \r\n \r\n", - []string{ " ", " " }, - }, - { - "aaa\r\nbbb\r\nccc\r\n", - []string{ "aaa", "bbb", "ccc" }, - }, - { - "\r\nsplit \r \n CRLF\r\n\r\n", - []string{ "", "split \r \n CRLF", "" }, - }, - } - - for _, entry := range table { - var given []string - scanner := bufio.NewScanner( - strings.NewReader(entry.input), - ) - scanner.Split(splitOnCRLF) - for scanner.Scan() { - given = append(given, scanner.Text()) - } - - err := scanner.Err() - g.TErrorIf(err) - g.TAssertEqual(given, entry.expected) - } - }) -} - -func test_splitOnRawMessage() { - g.TestStart("splitOnRawMessage()") - - g.Testing("empty messages get dropped", func() { - type tableT struct{ - input string - expected []string - } - table := []tableT{ - /* - FIXME - { - "\r\nfirst\r\n\r\nsecond\r\n \r\n", - []string{ "first", "second", " " }, - }, - */ - { - "first message\r\nsecond message\r\n", - []string{ "first message", "second message" }, - }, - { - "message 1\r\n\r\nmessage 2\r\n\r\nignored", - []string{ "message 1", "message 2" }, - }, - } - - - for _, entry := range table { - var given []string - scanner := bufio.NewScanner(strings.NewReader( - entry.input, - )) - scanner.Split(splitOnRawMessage) - for scanner.Scan() { - given = append(given, scanner.Text()) - } - - err := scanner.Err() - g.TErrorIf(err) - g.TAssertEqual(given, entry.expected) - } - }) -} - -func test_parseMessageParams() { - g.TestStart("parseMessageParams()") - - g.Testing("we can parse the string params", func() { - type tableT struct{ - input string - expected []string - } - table := []tableT{ - { "", []string{} }, - { " ", []string{} }, - { " :", []string{ "" } }, - { " : ", []string{ " " } }, - { ": ", []string{ ":" } }, - { ": ", []string{ ":" } }, - { " : ", []string{ " " } }, - { " :", []string{ "" } }, - { " :", []string{ "" } }, - { "a", []string{ "a" } }, - { "ab", []string{ "ab" } }, - { "a b", []string{ "a", "b" } }, - { "a b c", []string{ "a", "b", "c" } }, - { "a b:c", []string{ "a", "b:c" } }, - { "a b:c:", []string{ "a", "b:c:" } }, - { "a b :c", []string{ "a", "b", "c" } }, - { "a b :c:", []string{ "a", "b", "c:" } }, - { "a b :c ", []string{ "a", "b", "c " } }, - { "a b : c", []string{ "a", "b", " c" } }, - { "a b : c ", []string{ "a", "b", " c " } }, - { "a b : c :", []string{ "a", "b", " c :" } }, - { "a b : c : ", []string{ "a", "b", " c : " } }, - { "a b : c :d", []string{ "a", "b", " c :d" } }, - { "a b : c :d ", []string{ "a", "b", " c :d " } }, - { "a b : c : d ", []string{ "a", "b", " c : d " } }, - } - - for _, entry := range table { - given := parseMessageParams(entry.input) - g.TAssertEqual(given, entry.expected) - } - }) -} - -func test_parseMessage() { - g.TestStart("parseMessage()") - - g.Testing("we can parse a full message", func() { - type tableTOK struct{ - input string - expected messageT - } - tableOK := []tableTOK {{ - "NICK joebloe ", - messageT{ - prefix: "", - command: "NICK", - params: []string{ "joebloe" }, - raw: "NICK joebloe ", - }, - }, { - "USER joebloe 0.0.0.0 joe :Joe Bloe", - messageT{ - prefix: "", - command: "USER", - params: []string{ - "joebloe", - "0.0.0.0", - "joe", - "Joe Bloe", - }, - raw: "USER joebloe 0.0.0.0 joe :Joe Bloe", - }, - }, { - ":pre USER joebloe 0.0.0.0 joe :Joe Bloe", - messageT{ - prefix: "pre", - command: "USER", - params: []string{ - "joebloe", - "0.0.0.0", - "joe", - "Joe Bloe", - }, - raw: ":pre USER joebloe 0.0.0.0 joe :Joe Bloe", - }, - }, { - ":pre USER joebloe 0.0.0.0 joe : Joe Bloe ", - messageT{ - prefix: "pre", - command: "USER", - params: []string{ - "joebloe", - "0.0.0.0", - "joe", - " Joe Bloe ", - }, - raw: ":pre USER joebloe 0.0.0.0 " + - "joe : Joe Bloe ", - }, - }, { - ":pre USER jbloe: 0:0:0:1 joe::a: : Joe Bloe ", - messageT{ - prefix: "pre", - command: "USER", - params: []string{ - "jbloe:", - "0:0:0:1", - "joe::a:", - " Joe Bloe ", - }, - raw: ":pre USER jbloe: 0:0:0:1 " + - "joe::a: : Joe Bloe ", - }, - }, { - ":pre USER :Joe Bloe", - messageT{ - prefix: "pre", - command: "USER", - params: []string{ "Joe Bloe" }, - raw: ":pre USER :Joe Bloe", - }, - }, { - ":pre USER : Joe Bloe", - messageT{ - prefix: "pre", - command: "USER", - params: []string{ " Joe Bloe" }, - raw: ":pre USER : Joe Bloe", - }, - }, { - ":pre USER : Joe Bloe", - messageT{ - prefix: "pre", - command: "USER", - params: []string{ " Joe Bloe" }, - raw: ":pre USER : Joe Bloe", - }, - }, { - ":pre USER : ", - messageT{ - prefix: "pre", - command: "USER", - params: []string{ " " }, - raw: ":pre USER : ", - }, - }, { - ":pre USER :", - messageT{ - prefix: "pre", - command: "USER", - params: []string{}, - raw: ":pre USER :", - }, - }} - - for _, entry := range tableOK { - given, err := parseMessage(entry.input) - g.TErrorIf(err) - g.TAssertEqual(given, entry.expected) - } - }) - - - g.Testing("bad messages are rejected", func() { - type tableErrorT struct{ - input string - expected error - } - parseErr := errors.New("Can't parse message") - tableError := []tableErrorT{ - { - ":pre", - parseErr, - }, - { - ": pre", - parseErr, - }, - { - ":pre N1CK", - parseErr, - }, - } - - for _, entry := range tableError { - _, given := parseMessage(entry.input) - g.TAssertEqual(given, entry.expected) - } - }) -} - -func test_addTrailingSeparator() { - g.TestStart("addTrailingSeparator()") - - g.Testing("noop on empty slice", func() { - input := []string{} - addTrailingSeparator(input) - g.TAssertEqual(input, []string{}) - }) - - g.Testing("noop if last doesn't have a space", func() { - type tableT struct{ - input []string - expected []string - } - - entries := []tableT{ - { []string{ "" }, []string{ "" } }, - { []string{ "", "" }, []string{ "", "" } }, - { []string{ "a", "b" }, []string{ "a", "b" } }, - { - []string{ "a", "b", "c-d" }, - []string{ "a", "b", "c-d" }, - }, - { - []string{ "a ", "b", "cd" }, - []string{ "a ", "b", "cd" }, - }, - } - - for i, entry := range entries { - addTrailingSeparator(entry.input) - g.TAssertEqual(entry.input, entries[i].expected) - } - }) - - g.Testing("add ':' to the last otherwise", func() { - type tableT struct{ - input []string - expected []string - } - - entries := []tableT{ - { []string{ " " }, []string{ ": " } }, - { []string{ "a " }, []string{ ":a " } }, - { []string{ " a" }, []string{ ": a" } }, - { []string{ "a ", " b" }, []string{ "a ", ": b" } }, - { []string{ "a", " b" }, []string{ "a", ": b" } }, - { - []string{ "a", "b", "c d" }, - []string{ "a", "b", ":c d" }, - }, - } - - for i, entry := range entries { - addTrailingSeparator(entry.input) - addTrailingSeparator(entry.input) - g.TAssertEqual(entry.input, entries[i].expected) - } - }) -} - - -func dumpQueries() { - queries := []struct{name string; fn func(string) queryT}{ - { "createTables", createTablesSQL }, - { "memberRoles", memberRolesSQL }, - { "createUser", createUserSQL }, - { "userByUUID", userByUUIDSQL }, - { "updateUser", updateUserSQL }, - { "deleteUser", deleteUserSQL }, - { "addNetwork", addNetworkSQL }, - { "getNetwork", getNetworkSQL }, - { "networks", networksSQL }, - { "setNetwork", setNetworkSQL }, - { "nipNetwork", nipNetworkSQL }, - { "membership", membershipSQL }, - { "addMember", addMemberSQL }, - { "addRole", addRoleSQL }, - { "dropRole", dropRoleSQL }, - { "showMember", showMemberSQL }, - { "members", membersSQL }, - { "editMember", editMemberSQL }, - { "dropMember", dropMemberSQL }, - { "addChannel", addChannelSQL }, - { "channels", channelsSQL }, - { "setChannel", setChannelSQL }, - { "endChannel", endChannelSQL }, - { "join", joinSQL }, - { "part", partSQL }, - { "names", namesSQL }, - { "addEvent", addEventSQL }, - { "allAfter", allAfterSQL }, - { "logMessage", logMessageSQL }, - } - for _, query := range queries { - q := query.fn(defaultPrefix) - fmt.Printf("\n-- %s.sql:", query.name) - fmt.Printf("\n-- write:%s\n", q.write) - fmt.Printf("\n-- read:%s\n", q.read) - } -} - - - -func MainTest() { - if os.Getenv("TESTING_DUMP_SQL_QUERIES") != "" { - dumpQueries() - return - } - - g.Init() - test_defaultPrefix() - test_tryRollback() - test_inTx() - test_createTables() - test_createUserStmt() - test_userByUUIDStmt() - test_updateUserStmt() - test_deleteUserStmt() - test_addNetworkStmt() - test_getNetworkStmt() - test_networkEach() - test_networksStmt() - test_setNetworkStmt() - test_nipNetworkStmt() - test_membershipStmt() - test_addMemberStmt() - test_addRoleStmt() - test_dropRoleStmt() - test_showMemberStmt() - test_memberEach() - test_membersStmt() - test_editMemberStmt() - test_dropMemberStmt() - test_addChannelStmt() - test_channelEach() - test_channelsStmt() - test_setChannelStmt() - test_endChannelStmt() - test_joinStmt() - test_partStmt() - test_nameEach() - test_namesStmt() - test_addEventStmt() - test_eventEach() - test_allAfterStmt() - test_logMessageStmt() - test_initDB() - test_queriesTclose() - test_splitOnCRLF() - test_splitOnRawMessage() - test_parseMessageParams() - test_parseMessage() - test_addTrailingSeparator() -} diff --git a/tests/queries.sql b/tests/queries.sql deleted file mode 100644 index 992c8d2..0000000 --- a/tests/queries.sql +++ /dev/null @@ -1,1230 +0,0 @@ - --- createTables.sql: --- write: - -- TODO: unconfirmed premise: statements within a trigger are - -- part of the transaction that caused it, and so are - -- atomic. - -- See also: - -- https://stackoverflow.com/questions/77441888/ - -- https://stackoverflow.com/questions/30511116/ - - CREATE TABLE IF NOT EXISTS "papod_users" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - -- provided by cracha - user_uuid BLOB NOT NULL UNIQUE, - username TEXT NOT NULL, - display_name TEXT NOT NULL, - picture_uuid BLOB UNIQUE, - deleted INT NOT NULL CHECK(deleted IN (0, 1)) - ) STRICT; - CREATE TABLE IF NOT EXISTS "papod_user_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - user_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'username', - 'display_name', - 'picture_uuid', - 'deleted' - ) - ), - value_text TEXT, - value_blob BLOB, - value_bool INT CHECK(value_bool IN (0, 1)), - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - CREATE TRIGGER IF NOT EXISTS "papod_user_new" - AFTER INSERT ON "papod_users" - BEGIN - INSERT INTO "papod_user_changes" ( - user_id, attribute, value_text, op - ) VALUES - (NEW.id, 'username', NEW.username, true), - (NEW.id, 'display_name', NEW.display_name, true) - ; - INSERT INTO "papod_user_changes" ( - user_id, attribute, value_bool, op - ) VALUES - (NEW.id, 'deleted', NEW.deleted, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_user_new_picture_uuid" - AFTER INSERT ON "papod_users" - WHEN NEW.picture_uuid IS NOT NULL - BEGIN - INSERT INTO "papod_user_changes" ( - user_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_user_update_username" - AFTER UPDATE ON "papod_users" - WHEN OLD.username != NEW.username - BEGIN - INSERT INTO "papod_user_changes" ( - user_id, attribute, value_text, op - ) VALUES - (NEW.id, 'username', OLD.username, false), - (NEW.id, 'username', NEW.username, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_user_update_display_name" - AFTER UPDATE ON "papod_users" - WHEN OLD.display_name != NEW.display_name - BEGIN - INSERT INTO "papod_user_changes" ( - user_id, attribute, value_text, op - ) VALUES - (NEW.id, 'display_name', OLD.display_name, false), - (NEW.id, 'display_name', NEW.display_name, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_user_add_picture_uuid" - AFTER UPDATE ON "papod_users" - WHEN ( - OLD.picture_uuid IS NULL AND - NEW.picture_uuid IS NOT NULL - ) - BEGIN - INSERT INTO "papod_user_changes" ( - user_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_user_remove_picture_uuid" - AFTER UPDATE ON "papod_users" - WHEN ( - OLD.picture_uuid IS NOT NULL AND - NEW.picture_uuid IS NULL - ) - BEGIN - INSERT INTO "papod_user_changes" ( - user_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', OLD.picture_uuid, false) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_user_update_picture_uuid" - AFTER UPDATE ON "papod_users" - WHEN ( - OLD.picture_uuid IS NOT NULL AND - NEW.picture_uuid IS NOT NULL AND - OLD.picture_uuid != NEW.picture_uuid - ) - BEGIN - INSERT INTO "papod_user_changes" ( - user_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', OLD.picture_uuid, false), - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_user_update_deleted" - AFTER UPDATE ON "papod_users" - WHEN OLD.deleted != NEW.deleted - BEGIN - INSERT INTO "papod_user_changes" ( - user_id, attribute, value_bool, op - ) VALUES - (NEW.id, 'deleted', OLD.deleted, false), - (NEW.id, 'deleted', NEW.deleted, true) - ; - END; - - CREATE TABLE IF NOT EXISTS "papod_sessions" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - -- provided by cracha - session_uuid BLOB NOT NULL UNIQUE, - user_id INTEGER NOT NULL - REFERENCES "papod_users"(id), - finished_at TEXT - ); - CREATE TABLE IF NOT EXISTS "papod_connections" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - uuid BLOB NOT NULL UNIQUE, - finished_at TEXT - ); - CREATE TABLE IF NOT EXISTS "papod_logons" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - session_id INTEGER NOT NULL - REFERENCES "papod_sessions"(id), - connection_id INTEGER NOT NULL - REFERENCES "papod_connections"(id), - UNIQUE (session_id, connection_id) - ) STRICT; - - CREATE TABLE IF NOT EXISTS "papod_networks" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - uuid BLOB NOT NULL UNIQUE, - name TEXT NOT NULL, - description TEXT NOT NULL, - type TEXT NOT NULL CHECK( - type IN ('public', 'private', 'unlisted') - ), - deleted INT NOT NULL CHECK(deleted IN (0, 1)) - ) STRICT; - CREATE INDEX IF NOT EXISTS "papod_networks_type" - ON "papod_networks"(type); - CREATE TABLE IF NOT EXISTS "papod_network_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - network_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'name', - 'description', - 'type', - 'deleted', - 'logon_id' -- FIXME - ) - ), - value TEXT NOT NULL, - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - CREATE TRIGGER IF NOT EXISTS "papod_network_new" - AFTER INSERT ON "papod_networks" - BEGIN - INSERT INTO "papod_network_changes" ( - network_id, attribute, value, op - ) VALUES - (NEW.id, 'name', NEW.name, true), - (NEW.id, 'description', NEW.description, true), - (NEW.id, 'type', NEW.type, true), - (NEW.id, 'deleted', NEW.deleted, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_network_update_name" - AFTER UPDATE ON "papod_networks" - WHEN OLD.name != NEW.name - BEGIN - INSERT INTO "papod_network_changes" ( - network_id, attribute, value, op - ) VALUES - (NEW.id, 'name', OLD.name, false), - (NEW.id, 'name', NEW.name, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_network_update_description" - AFTER UPDATE ON "papod_networks" - WHEN OLD.description != NEW.description - BEGIN - INSERT INTO "papod_network_changes" ( - network_id, attribute, value, op - ) VALUES - (NEW.id, 'description', OLD.description, false), - (NEW.id, 'description', NEW.description, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_network_update_type" - AFTER UPDATE ON "papod_networks" - WHEN OLD.description != NEW.description - BEGIN - INSERT INTO "papod_network_changes" ( - network_id, attribute, value, op - ) VALUES - (NEW.id, 'type', OLD.type, false), - (NEW.id, 'type', NEW.type, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_network_update_deleted" - AFTER UPDATE ON "papod_networks" - WHEN OLD.deleted != NEW.deleted - BEGIN - INSERT INTO "papod_network_changes" ( - network_id, attribute, value, op - ) VALUES - (NEW.id, 'deleted', OLD.deleted, false), - (NEW.id, 'deleted', NEW.deleted, true) - ; - END; - - CREATE TABLE IF NOT EXISTS "papod_members" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - uuid BLOB NOT NULL UNIQUE, - network_id INTEGER NOT NULL - REFERENCES "papod_networks"(id), - user_id INTEGER NOT NULL, - username TEXT NOT NULL, - display_name TEXT NOT NULL, - picture_uuid BLOB UNIQUE, - status TEXT NOT NULL CHECK( - status IN ('active', 'inactive', 'removed') - ), - active_uniq TEXT CHECK( - active_uniq IN ('active', NULL) - ), - UNIQUE (network_id, username, active_uniq), - UNIQUE (network_id, user_id) - ) STRICT; - CREATE TABLE IF NOT EXISTS "papod_member_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - member_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'username', - 'display_name', - 'picture_uuid', - 'status', - 'logon_id' -- FIXME - ) - ), - value_text TEXT, - value_blob BLOB, - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - CREATE TRIGGER IF NOT EXISTS "papod_member_new" - AFTER INSERT ON "papod_members" - BEGIN - INSERT INTO "papod_member_changes" ( - member_id, attribute, value_text, op - ) VALUES - (NEW.id, 'username', NEW.username, true), - (NEW.id, 'display_name', NEW.display_name, true), - (NEW.id, 'status', NEW.status, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_member_new_picture_uuid" - AFTER INSERT ON "papod_members" - WHEN NEW.picture_uuid IS NOT NULL - BEGIN - INSERT INTO "papod_member_changes" ( - member_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_member_update_username" - AFTER UPDATE ON "papod_members" - WHEN OLD.username != NEW.username - BEGIN - INSERT INTO "papod_member_changes" ( - member_id, attribute, value_text, op - ) VALUES - (NEW.id, 'username', OLD.username, false), - (NEW.id, 'username', NEW.username, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_member_update_display_name" - AFTER UPDATE ON "papod_members" - WHEN OLD.display_name != NEW.display_name - BEGIN - INSERT INTO "papod_member_changes" ( - member_id, attribute, value_text, op - ) VALUES - (NEW.id, 'display_name', OLD.display_name, false), - (NEW.id, 'display_name', NEW.display_name, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_member_update_status" - AFTER UPDATE ON "papod_members" - WHEN OLD.status != NEW.status - BEGIN - INSERT INTO "papod_member_changes" ( - member_id, attribute, value_text, op - ) VALUES - (NEW.id, 'status', OLD.status, false), - (NEW.id, 'status', NEW.status, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_member_add_picture_uuid" - AFTER UPDATE ON "papod_members" - WHEN ( - OLD.picture_uuid IS NULL AND - NEW.picture_uuid IS NOT NULL - ) - BEGIN - INSERT INTO "papod_member_changes" ( - member_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_member_remove_picture_uuid" - AFTER UPDATE ON "papod_members" - WHEN ( - OLD.picture_uuid IS NOT NULL AND - NEW.picture_uuid IS NULL - ) - BEGIN - INSERT INTO "papod_member_changes" ( - member_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', OLD.picture_uuid, false) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_member_update_picture_uuid" - AFTER UPDATE ON "papod_members" - WHEN ( - OLD.picture_uuid IS NOT NULL AND - NEW.picture_uuid IS NOT NULL AND - OLD.picture_uuid != NEW.picture_uuid - ) - BEGIN - INSERT INTO "papod_member_changes" ( - member_id, attribute, value_blob, op - ) VALUES - (NEW.id, 'picture_uuid', OLD.picture_uuid, false), - (NEW.id, 'picture_uuid', NEW.picture_uuid, true) - ; - END; - - CREATE TABLE IF NOT EXISTS "papod_member_roles" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - member_id INTEGER NOT NULL - REFERENCES "papod_members"(id), - role TEXT NOT NULL, - UNIQUE (member_id, role) - ) STRICT; - CREATE TABLE IF NOT EXISTS "papod_member_role_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - role_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'role', - 'logon_id' -- FIXME - ) - ), - value TEXT NOT NULL, - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - CREATE TRIGGER IF NOT EXISTS "papod_member_role_add" - AFTER INSERT ON "papod_member_roles" - BEGIN - INSERT INTO "papod_member_role_changes" ( - role_id, attribute, value, op - ) VALUES - (NEW.id, 'role', NEW.role, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_member_role_remove" - AFTER DELETE ON "papod_member_roles" - BEGIN - INSERT INTO "papod_member_role_changes" ( - role_id, attribute, value, op - ) VALUES - (OLD.id, 'role', OLD.role, false) - ; - END; - - CREATE TABLE IF NOT EXISTS "papod_channels" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - uuid BLOB NOT NULL UNIQUE, - network_id INTEGER NOT NULL - REFERENCES "papod_networks"(id), - public_name TEXT, - label TEXT NOT NULL, - description TEXT NOT NULL, - virtual INT NOT NULL CHECK(virtual IN (0, 1)), - UNIQUE (network_id, public_name) - ) STRICT; - CREATE TABLE IF NOT EXISTS "papod_channel_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - channel_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'public_name', - 'label', - 'description', - 'virtual', - 'logon_id' -- FIXME - ) - ), - value_text TEXT, - value_bool INT CHECK(value_bool IN (0, 1)), - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - CREATE TRIGGER IF NOT EXISTS "papod_channel_new" - AFTER INSERT ON "papod_channels" - BEGIN - INSERT INTO "papod_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (NEW.id, 'label', NEW.label, true), - (NEW.id, 'description', NEW.description, true) - ; - INSERT INTO "papod_channel_changes" ( - channel_id, attribute, value_bool, op - ) VALUES - (NEW.id, 'virtual', NEW.virtual, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_channel_new_public_name" - AFTER INSERT ON "papod_channels" - WHEN NEW.public_name IS NOT NULL - BEGIN - INSERT INTO "papod_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (NEW.id, 'public_name', NEW.public_name, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_channel_update_label" - AFTER UPDATE ON "papod_channels" - WHEN OLD.label != NEW.label - BEGIN - INSERT INTO "papod_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (NEW.id, 'label', OLD.label, false), - (NEW.id, 'label', NEW.label, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_channel_update_description" - AFTER UPDATE ON "papod_channels" - WHEN OLD.description != NEW.description - BEGIN - INSERT INTO "papod_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (NEW.id, 'description', OLD.description, false), - (NEW.id, 'description', NEW.description, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_channel_update_virtual" - AFTER UPDATE ON "papod_channels" - WHEN OLD.virtual != NEW.virtual - BEGIN - INSERT INTO "papod_channel_changes" ( - channel_id, attribute, value_bool, op - ) VALUES - (NEW.id, 'virtual', OLD.virtual, false), - (NEW.id, 'virtual', NEW.virtual, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_channel_add_public_name" - AFTER UPDATE ON "papod_channels" - WHEN ( - OLD.public_name IS NULL AND - NEW.public_name IS NOT NULL - ) - BEGIN - INSERT INTO "papod_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (NEW.id, 'public_name', NEW.public_name, true) - ; - END; - CREATE TRIGGER IF NOT EXISTS "papod_channel_remove_public_name" - AFTER UPDATE ON "papod_channels" - WHEN ( - OLD.public_name IS NOT NULL AND - NEW.public_name IS NULL - ) - BEGIN - INSERT INTO "papod_channel_changes" ( - channel_id, attribute, value_text, op - ) VALUES - (OLD.id, 'public_name', OLD.public_name, false) - ; - END; - - CREATE TABLE IF NOT EXISTS "papod_participants" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL - REFERENCES "papod_channels"(id), - member_id INTEGER NOT NULL - REFERENCES "papod_members"(id), - UNIQUE (channel_id, member_id) - ) STRICT; - CREATE TABLE IF NOT EXISTS "papod_participant_changes" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - participant_id INTEGER NOT NULL, - attribute TEXT NOT NULL CHECK( - attribute IN ( - 'connection_id' - ) - ), - value TEXT NOT NULL, - op INT NOT NULL CHECK(op IN (0, 1)) - ) STRICT; - - CREATE TABLE IF NOT EXISTS "papod_channel_events" ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL DEFAULT ( - strftime('%Y-%m-%dT%H:%M:%f000000Z', 'now') - ), - uuid BLOB NOT NULL UNIQUE, - channel_id INTEGER NOT NULL - REFERENCES "papod_channels"(id), - source_uuid BLOB NOT NULL, - source_type TEXT NOT NULL CHECK( - source_type IN ( - 'logon' - ) - ), - source_metadata TEXT, - type TEXT NOT NULL CHECK( - type IN ( - 'user-join', - 'user-message' - ) - ), - payload TEXT NOT NULL, - metadata TEXT - ) STRICT; - - - --- read: - --- memberRoles.sql: --- write: - --- read: - SELECT role FROM "papod_member_roles" - JOIN "papod_members" ON - "papod_member_roles".member_id = "papod_members".id - WHERE "papod_members".uuid = ? - ORDER BY "papod_member_roles".id; - - --- createUser.sql: --- write: - INSERT INTO "papod_users" ( - user_uuid, username, display_name, picture_uuid, deleted - ) VALUES ( - ?, ?, ?, NULL, false - ) RETURNING id, timestamp; - - --- read: - --- userByUUID.sql: --- write: - --- read: - SELECT - id, - timestamp, - username, - display_name, - picture_uuid - FROM "papod_users" - WHERE - user_uuid = ? AND - deleted = false; - - --- updateUser.sql: --- write: - UPDATE "papod_users" - SET - username = ?, - display_name = ?, - picture_uuid = ? - WHERE - id = ? AND - deleted = false - RETURNING id; - - --- read: - --- deleteUser.sql: --- write: - UPDATE "papod_users" - SET deleted = true - WHERE - user_uuid = ? AND - deleted = false - RETURNING id; - - --- read: - --- addNetwork.sql: --- write: - INSERT INTO "papod_networks" ( - uuid, name, description, type, deleted - ) - VALUES ( - ?, - ?, - ?, - ?, - false - ) RETURNING id; - - WITH creator AS ( - SELECT username, display_name, picture_uuid - FROM "papod_users" - WHERE id = ? AND deleted = false - ), new_network AS ( - SELECT id FROM "papod_networks" WHERE uuid = ? - ) - INSERT INTO "papod_members" ( - uuid, network_id, user_id, username, display_name, - picture_uuid, status, active_uniq - ) VALUES ( - ?, - (SELECT id FROM new_network), - ?, - (SELECT username FROM creator), - (SELECT display_name FROM creator), - (SELECT picture_uuid FROM creator), - 'active', - 'active' - ) RETURNING id; - - WITH new_member AS ( - SELECT id FROM "papod_members" WHERE uuid = ? - ) - INSERT INTO "papod_member_roles" (member_id, role) - VALUES ( - (SELECT id FROM new_member), - 'admin' - ), - ( - (SELECT id FROM new_member), - 'creator' - ) - RETURNING id; - - --- read: - SELECT id, timestamp FROM "papod_networks" - WHERE uuid = ? AND deleted = false; - - --- getNetwork.sql: --- write: - --- read: - WITH probing_user AS ( - SELECT id FROM "papod_users" - WHERE id = ? AND deleted = false - ), target_network AS ( - SELECT id FROM "papod_networks" - WHERE uuid = ? AND deleted = false - ) - SELECT - id, - timestamp, - name, - description, - type - FROM "papod_networks" - WHERE - uuid = ? AND - deleted = false AND - ? IN probing_user AND - ( - type IN ('public', 'unlisted') OR - ? IN ( - SELECT user_id FROM "papod_members" - WHERE - user_id = ? AND - network_id IN target_network AND - status != 'removed' - ) - ); - %!(EXTRA string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod, string=papod) - --- networks.sql: --- write: - --- read: - WITH current_user AS ( - SELECT id, deleted FROM "papod_users" WHERE id = ? - ) - SELECT - "papod_networks".id, - "papod_networks".timestamp, - "papod_networks".uuid, - "papod_networks".name, - "papod_networks".description, - "papod_networks".type, - (SELECT deleted FROM current_user) - FROM "papod_networks" - JOIN "papod_members" ON - "papod_networks".id = "papod_members".network_id - WHERE ( - "papod_networks".type = 'public' OR - "papod_networks".id IN ( - SELECT network_id FROM "papod_members" - WHERE user_id IN (SELECT id FROM current_user) - ) - ) AND "papod_networks".deleted = false - ORDER BY "papod_networks".id; - - --- setNetwork.sql: --- write: - UPDATE "papod_networks" - SET - name = ?, - description = ?, - type = ? - WHERE id = ? AND deleted = false - RETURNING ( - SELECT CASE WHEN EXISTS ( - SELECT role from "papod_member_roles" - WHERE - member_id = ? AND - role IN ( - 'admin', - 'network-settings-update' - ) AND ? IN ( - SELECT network_id - FROM "papod_members" - WHERE - id = ? AND - status = 'active' - ) - ) THEN true ELSE RAISE( - ABORT, - 'member not allowed to update network data' - ) END - ); - - - --- read: - --- nipNetwork.sql: --- write: - WITH target_network AS ( - SELECT network_id AS id - FROM "papod_members" - WHERE - id = ? AND - status = 'active' - ) - UPDATE "papod_networks" - SET deleted = true - WHERE id IN target_network AND deleted = false - RETURNING ( - SELECT CASE WHEN EXISTS ( - SELECT role FROM "papod_member_roles" - WHERE - member_id = ? AND - role IN ( - 'admin' - ) - ) THEN true ELSE RAISE( - ABORT, - 'member not allowed to delete network' - ) END - ); - - --- read: - --- membership.sql: --- write: - --- read: - SELECT - "papod_members".id, - "papod_members".timestamp, - "papod_members".uuid, - "papod_members".username, - "papod_members".display_name, - "papod_members".picture_uuid, - "papod_members".status - FROM "papod_members" - JOIN "papod_users" ON - "papod_users".id = "papod_members".user_id - JOIN "papod_networks" ON - "papod_networks".id = "papod_members".network_id - WHERE - "papod_members".user_id = ? AND - "papod_members".network_id = ? AND - "papod_members".status = 'active' AND - "papod_users".deleted = false AND - "papod_networks".deleted = false; - - --- addMember.sql: --- write: - WITH target_user AS ( - SELECT id, username, display_name, picture_uuid - FROM "papod_users" - WHERE user_uuid = ? AND deleted = false - ), target_network AS ( - SELECT "papod_members".network_id AS id - FROM "papod_members" - JOIN "papod_networks" ON - "papod_members".network_id = "papod_networks".id - WHERE - "papod_members".id = ? AND - "papod_members".status = 'active' AND - "papod_networks".deleted = false - ) - INSERT INTO "papod_members" ( - uuid, network_id, user_id, username, display_name, - picture_uuid, status, active_uniq - ) VALUES ( - ?, - (SELECT id FROM target_network), - (SELECT id FROM target_user), - ?, - (SELECT display_name FROM target_user), - (SELECT picture_uuid FROM target_user), - 'active', - 'active' - ) RETURNING id, timestamp, display_name, picture_uuid, status, ( - SELECT CASE WHEN EXISTS ( - SELECT role from "papod_member_roles" - WHERE - member_id = ? AND - role IN ( - 'admin', - 'add-member' - ) - ) THEN true ELSE RAISE( - ABORT, - 'member not allowed to add another member' - ) END - ); - - --- read: - --- addRole.sql: --- write: - INSERT INTO "papod_member_roles" (member_id, role) - VALUES (?, ?); - - --- read: - --- dropRole.sql: --- write: - DELETE FROM "papod_member_roles" - WHERE - member_id = ? AND - role = ? - RETURNING 1; - - --- read: - --- showMember.sql: --- write: - --- read: - WITH current_network AS ( - SELECT network_id - FROM "papod_members" - WHERE id = ? - ) - SELECT - id, - timestamp, - username, - display_name, - picture_uuid, - status - FROM "papod_members" - WHERE - uuid = ? AND - network_id IN current_network; - - --- members.sql: --- write: - --- read: - WITH target_network AS ( - SELECT "papod_members".network_id - FROM "papod_members" - JOIN "papod_networks" ON - "papod_members".network_id = "papod_networks".id - WHERE - "papod_members".id = ? AND - "papod_networks".deleted = false - ) - SELECT - id, - timestamp, - uuid, - username, - display_name, - picture_uuid, - status - FROM "papod_members" - WHERE - network_id IN target_network AND - status = 'active'; - - --- editMember.sql: --- write: - UPDATE "papod_members" - SET - status = ? - WHERE id = ? - RETURNING id; - - --- read: - --- dropMember.sql: --- write: - UPDATE "papod_members" SET status = 'removed' - WHERE uuid = ? RETURNING id; - - DELETE FROM "papod_member_roles" - WHERE - role != 'creator' AND - member_id IN ( - SELECT id FROM "papod_members" - WHERE uuid = ? - ) - - --- read: - --- addChannel.sql: --- write: - WITH target_network AS ( - SELECT network_id AS id - FROM "papod_members" - WHERE id = ? - ) - INSERT INTO "papod_channels" ( - uuid, - network_id, - public_name, - label, - description, - virtual - ) VALUES ( - ?, - (SELECT id FROM target_network), - ?, - ?, - ?, - ? - ) RETURNING id, timestamp; - - WITH new_channel AS ( - SELECT id FROM "papod_channels" WHERE uuid = ? - ) - INSERT INTO "papod_participants" (channel_id, member_id) - VALUES ( - (SELECT id FROM new_channel), - ? - ); - - --- read: - SELECT id, timestamp FROM "papod_channels" - WHERE uuid = ?; - - --- channels.sql: --- write: - --- read: - WITH current_network AS ( - SELECT network_id AS id - FROM "papod_members" - WHERE id = ? - ), member_private_channels AS ( - SELECT channel_id AS id - FROM "papod_participants" - WHERE member_id = ? - ) - SELECT - id, - timestamp, - uuid, - public_name, - label, - description, - virtual - FROM "papod_channels" - WHERE - network_id IN current_network AND - ( - public_name IS NOT NULL OR - id IN member_private_channels - ) - ORDER BY id; - - --- setChannel.sql: --- write: - WITH participant_channel AS ( - SELECT channel_id AS id - FROM "papod_participants" - WHERE - member_id = ? AND - channel_id = ? - ) - UPDATE "papod_channels" - SET - description = ?, - public_name = ? - WHERE id IN participant_channel - RETURNING id; - - --- read: - SELECT ( - SELECT network_id AS id - FROM "papod_channels" - WHERE id = ? - ) AS channel_network_id, ( - SELECT network_id AS id - FROM "papod_members" - WHERE id = ? - ) AS member_network_id; - - --- endChannel.sql: --- write: - -- FIXME papod - - --- read: - --- join.sql: --- write: - WITH target_channel AS ( - SELECT id - FROM "papod_channels" - WHERE - uuid = ? AND - public_name IS NOT NULL - ) - INSERT INTO "papod_participants" (channel_id, member_id) - VALUES ( - (SELECT id FROM target_channel), - ? - ) RETURNING id; - - --- read: - SELECT ( - SELECT network_id AS id - FROM "papod_channels" - WHERE - uuid = ? AND - public_name IS NOT NULL - ) AS channel_network_id, ( - SELECT network_id AS id - FROM "papod_members" WHERE id = ? - ) AS member_network_id; - - --- part.sql: --- write: - WITH target_channel AS ( - SELECT id - FROM "papod_channels" - WHERE - id = ? AND - virtual = false - ) - DELETE FROM "papod_participants" - WHERE - member_id = ? AND - channel_id IN target_channel - RETURNING 1; - - --- read: - --- names.sql: --- write: - --- read: - -- FIXME papod - - --- addEvent.sql: --- write: - INSERT INTO "papod_channel_events" ( - uuid, channel_id, source_uuid, source_type, - source_metadata, type, payload, metadata - ) VALUES ( - ?, - (SELECT id FROM "papod_channels" WHERE uuid = ?), - ?, - ?, - ?, - ?, - ?, - ? - ) RETURNING id, timestamp; - - --- read: - --- allAfter.sql: --- write: - --- read: - WITH landmark_event AS ( - SELECT id, channel_id - FROM "papod_channel_events" - WHERE uuid = ? - ) - SELECT - "papod_channel_events".id, - "papod_channel_events".timestamp, - "papod_channel_events".uuid, - "papod_channels".uuid, - -- "papod_channel_events".connection_uuid, - "papod_channel_events".type, - "papod_channel_events".payload - FROM "papod_channel_events" - JOIN "papod_channels" ON - "papod_channel_events".channel_id = "papod_channels".id - WHERE - "papod_channel_events".id > ( - SELECT id FROM landmark_event - ) AND channel_id = ( - SELECT channel_id FROM landmark_event - ); - - --- logMessage.sql: --- write: - -- FIXME papod - - --- read: diff --git a/tests/unit.clj b/tests/unit.clj new file mode 100644 index 0000000..93e3703 --- /dev/null +++ b/tests/unit.clj @@ -0,0 +1,2029 @@ +(ns unit + (:require [clojure.test :as t :refer [are deftest is testing]] + [clojure.string :as string] + [cracha] + [datomic.api :as d] + [fiinha] + [papod]) + (:gen-class)) + + + +(def parse @#'papod/get-raw-message) +(deftest test_get-raw-message + (testing ":needs-more states" + (is (= (parse "") + {:input "" + :status :needs-more})) + (is (= (parse "some text") + {:input "some text" + :status :needs-more})) + (is (= (parse "newline\nonly") + {:input "newline\nonly" + :status :needs-more})) + (is (= (parse "CR\rLF\n\r") + {:input "CR\rLF\n\r" + :status :needs-more}))) + (testing "empty messages" + (is (= (parse " \r\nblah\r\n") + {:status :accepting + :input "blah\r\n" + :raw-message false}))) + (testing "input splitting" + (is (= (parse "BEFORE\r\nAFTER\r\nTAIL") + {:status :accepting + :input "AFTER\r\nTAIL" + :raw-message "BEFORE"})))) + +(def parse-message @#'papod/parse-message) +(deftest test_parse-message + (testing "only commands" + (is (= (parse-message "CMD") + [{:tags nil :prefix nil + :command "CMD" + :params []} + nil]))) + (testing "prefix with nick only" + (is (= (parse-message ":nick CMD") + [{:tags nil :prefix {:nick "nick" :user nil :host nil} + :command "CMD" + :params []} + nil]))) + (testing "prefix with nick and user" + (is (= (parse-message ":nick!user CMD") + [{:tags nil :prefix {:nick "nick" :user "user" :host nil} + :command "CMD" + :params []} + nil]))) + (testing "prefix with nick and host" + (is (= (parse-message ":nick@host CMD") + [{:tags nil :prefix {:nick "nick" :user nil :host "host"} + :command "CMD" + :params []} + nil]))) + (testing "prefix with nick, user and host" + (is (= (parse-message ":nick!user@host CMD") + [{:tags nil :prefix {:nick "nick" :user "user" :host "host"} + :command "CMD" + :params []} + nil]))) + (testing "prefix errors from parse-prefix" + (is (= :bad-empty-prefix-and-message + (-> (parse-message ":") second :type))) + (is (= :bad-empty-prefix + (-> (parse-message ": CMD") second :type))) + (is (= :no-prefix-separator + (-> (parse-message ":nospaceatall") second :type)))) + (testing "prefix errors from parse-prefix-components" + (is (= :bad-prefix-format + (-> (parse-message ":a@b!c CMD") second :type))) + (is (= :bad-empty-nick + (-> (parse-message ":@host CMD") second :type))) + (is (= :bad-empty-user + (-> (parse-message ":nick!@host CMD") second :type))) + (is (= :bad-empty-host + (-> (parse-message ":nick!user@ CMD") second :type))) + (is (= :bad-empty-nick + (-> (parse-message ":!user CMD") second :type))))) + +(def replies-for! @#'papod/replies-for!) +(def clean-params @#'papod/clean-params) +(defn client [] (atom {:nick nil :user nil :pass nil :registered? false})) +(def no-conn {}) + +(defn test-components + [] + (let [fiinha-state (fiinha/initdb! + (str "datomic:mem://fiinha-" (java.util.UUID/randomUUID))) + cracha-state (cracha/init! + (str "datomic:mem://cracha-" (java.util.UUID/randomUUID)) + fiinha-state) + papod-uri (str "datomic:mem://papod-" (java.util.UUID/randomUUID)) + _ (d/create-database papod-uri) + conn (d/connect papod-uri)] + @(d/transact conn @#'papod/schema) + (let [proc-id (java.util.UUID/randomUUID)] + @(d/transact conn + [{:db/ensure :papod.process/attrs + :papod.process/id proc-id + :papod.process/pid 0 + :papod.process/hostname "test" + :papod.process/started-at (java.util.Date.)}]) + {:conn conn :cracha cracha-state :process-id proc-id + :clients (atom {}) :channels (atom {}) + :ops (atom {}) :voiced (atom {}) + :chan-modes (atom {}) + :chan-keys (atom {}) + :chan-limits (atom {}) + :invites (atom {}) + :whowas (atom []) + :max-users (atom 0) + :n-unreg (atom 0)}))) + +(defn test-network! + [conn] + (let [net-id (java.util.UUID/randomUUID)] + @(d/transact conn + [{:db/ensure :papod.network/attrs + :papod.network/id net-id + :papod.network/name (str "test-" net-id) + :papod.network/description "" + :papod.network/type "public" + :papod.network/created-at (java.util.Date.)}]) + net-id)) + +(defn test-components-with-network + [] + (let [components (test-components) + net-id (test-network! (:conn components))] + (assoc components :test-network-id net-id))) + +(deftest test_clean-params + (testing "removes leading empty strings" + (is (= (clean-params ["" "a" "b"]) ["a" "b"])) + (is (= (clean-params ["a" "b"]) ["a" "b"])) + (is (= (clean-params []) [])))) + +(deftest test_replies-for! + (testing "PASS selects network" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + c (client)] + (is (= (replies-for! {:command "PASS" + :params ["" (str test-network-id)]} + c components) + [])) + (is (= (:network-id @c) test-network-id)))) + (testing "PASS with invalid UUID" + (let [components (test-components) + c (client)] + (is (string/includes? + (first (replies-for! {:command "PASS" :params ["" "not-a-uuid"]} + c components)) + "403")))) + (testing "PASS with no params" + (let [c (client)] + (is (string/includes? + (first (replies-for! {:command "PASS" :params []} + c no-conn)) + "461")))) + (testing "NICK stores nickname" + (let [c (client)] + (is (= (replies-for! {:command "NICK" :params ["" "joe"]} + c no-conn) + [])) + (is (= (:nick @c) "joe")))) + (testing "NICK with no params" + (let [c (client)] + (is (string/includes? + (first (replies-for! {:command "NICK" :params []} + c no-conn)) + "431")))) + (testing "registration completes after NICK and USER" + (let [c (client)] + (replies-for! {:command "NICK" :params ["" "joe"]} c no-conn) + (let [replies (replies-for! + {:command "USER" + :params ["" "joe" "0" "x" ":Joe"]} + c no-conn)] + ;; 001, 002, 003, 004, three RPL_005 lines, 422. + (is (= 8 (count replies))) + (is (string/includes? (first replies) "001")) + (is (:registered? @c))))) + (testing "USER before NICK does not register" + (let [c (client)] + (let [replies (replies-for! + {:command "USER" + :params ["" "joe" "0" "x" ":Joe"]} + c no-conn)] + (is (empty? replies)) + (is (not (:registered? @c)))))) + (testing "USER after registration" + (let [c (client)] + (replies-for! {:command "NICK" :params ["" "joe"]} c no-conn) + (replies-for! {:command "USER" :params ["" "joe" "0" "x" ":Joe"]} + c no-conn) + (is (string/includes? + (first (replies-for! + {:command "USER" :params ["" "joe" "0" "x" ":Joe"]} + c no-conn)) + "462")))) + (testing "PASS after registration" + (let [c (client)] + (replies-for! {:command "NICK" :params ["" "joe"]} c no-conn) + (replies-for! {:command "USER" :params ["" "joe" "0" "x" ":Joe"]} + c no-conn) + (is (string/includes? + (first (replies-for! {:command "PASS" :params ["" "pw"]} + c no-conn)) + "462")))) + (testing "unregistered client gets 451 for other commands" + (let [c (client)] + (is (string/includes? + (first (replies-for! {:command "JOIN" :params ["" "#chan"]} + c no-conn)) + "451")))) + (testing "PING responds with PONG" + (let [c (client)] + (let [replies (replies-for! + {:command "PING" :params ["" "token"]} + c no-conn)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "PONG")) + (is (string/includes? (first replies) "token"))))) + (testing "PING with no params" + (let [c (client)] + (is (string/includes? + (first (replies-for! {:command "PING" :params []} + c no-conn)) + "409")))) + (testing "USER with empty realname" + (let [c (client)] + (replies-for! {:command "NICK" :params ["" "joe"]} + c no-conn) + (let [replies (replies-for! + {:command "USER" + :params ["" "joe" "0" "*" ":"]} + c no-conn)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "461")) + (is (not (:registered? @c)))))) + (testing "unknown command returns 421" + (let [{:keys [test-network-id] :as components} + (test-components-with-network) + c (client)] + (replies-for! {:command "PASS" + :params ["" (str test-network-id)]} + c components) + (replies-for! {:command "NICK" :params ["" "joe"]} + c components) + (replies-for! {:command "USER" + :params ["" "joe" "0" "*" ":Joe"]} + c components) + (is (string/includes? + (first (replies-for! + {:command "FOOBAR" :params []} + c components)) + "421")))) + (testing "AWAY sets and clears away status" + (let [c (client)] + (replies-for! {:command "NICK" :params ["" "joe"]} + c no-conn) + (replies-for! {:command "USER" + :params ["" "joe" "0" "*" ":Joe"]} + c no-conn) + ;; Set away + (let [replies (replies-for! + {:command "AWAY" + :params ["" ":I'm not here"]} + c no-conn)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "306")) + (is (= "I'm not here" (:away @c)))) + ;; Clear away + (let [replies (replies-for! + {:command "AWAY" :params []} + c no-conn)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "305")) + (is (nil? (:away @c)))))) + (testing "QUIT sets :quit? and returns ERROR" + (let [c (client)] + (let [replies (replies-for! + {:command "QUIT" + :params [":Goodbye"]} + c no-conn)] + (is (= 1 (count replies))) + (is (string/starts-with? (first replies) "ERROR")) + (is (:quit? @c)))))) + +(defn registered-client + ([nick w] + (atom {:nick nick :user {:username nick} :pass nil :registered? true :w w})) + ([nick w net-id] + (atom {:nick nick :user {:username nick} :pass nil :registered? true + :w w :network-id net-id}))) + +(def handle-privmsg @#'papod/handle-privmsg) +(def handle-join @#'papod/handle-join) +(def resolve-channel @#'papod/resolve-channel) +(def handle-cap @#'papod/handle-cap) +(def handle-authenticate @#'papod/handle-authenticate) + +(deftest test_persist-first + (testing "DM persists before delivery" + (let [sender-out (java.io.ByteArrayOutputStream.) + target-out (java.io.ByteArrayOutputStream.) + sender (registered-client "alice" sender-out) + components (assoc (test-components) + :clients (atom {"alice" {:w sender-out} + "bob" {:w target-out}}) + :channels (atom {})) + conn (:conn components)] + (handle-privmsg ["bob" ":hello world"] nil sender components) + ;; Verify event persisted with target-nick + (let [db (d/db conn) + events (d/q '{:find [?nick ?target ?payload] + :where [[?e :papod.event/source-nick ?nick] + [?e :papod.event/target-nick ?target] + [?e :papod.event/payload ?payload]]} + db)] + (is (= 1 (count events))) + (is (= ["alice" "bob" ":hello world"] + (vec (first events))))) + ;; Delivered to target, not sender + (is (string/includes? (.toString target-out "UTF-8") + "PRIVMSG bob :hello world")) + (is (= "" (.toString sender-out "UTF-8"))))) + (testing "channel PRIVMSG persists with channel ref" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}})) + sender (registered-client "alice" alice-out test-network-id) + conn (:conn components)] + ;; Create the channel in DB first (JOIN would do this normally) + @(d/transact conn + [{:papod.channel/id (java.util.UUID/randomUUID) + :papod.channel/network [:papod.network/id test-network-id] + :papod.channel/name "#test" + :papod.channel/type :papod.channel.type/public + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}]) + (handle-privmsg ["#test" ":hi everyone"] nil sender components) + ;; Verify event references the channel entity + (let [db (d/db conn) + events (d/q '{:find [?type ?payload ?chan-name] + :where [[?e :papod.event/channel ?c] + [?e :papod.event/type ?type] + [?e :papod.event/payload ?payload] + [?c :papod.channel/name ?chan-name]]} + db)] + (is (= 1 (count events))) + (is (= ["user-message" ":hi everyone" "#test"] + (vec (first events))))) + ;; Delivered to bob, not alice + (is (string/includes? (.toString bob-out "UTF-8") + "PRIVMSG #test :hi everyone")) + (is (= "" (.toString alice-out "UTF-8"))))) + (testing "JOIN creates channel + membership + event atomically" + (let [alice-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out}}) + :channels (atom {})) + sender (registered-client "alice" alice-out test-network-id) + conn (:conn components) + replies (handle-join ["#test"] sender components)] + (let [db (d/db conn)] + ;; Channel entity created + (is (some? (resolve-channel db "#test"))) + ;; Membership recorded + (let [memberships (d/q '{:find [?nick ?chan-name] + :where [[?m :papod.membership/nick ?nick] + [?m :papod.membership/channel ?c] + [?c :papod.channel/name ?chan-name]]} + db)] + (is (= #{["alice" "#test"]} memberships))) + ;; Event recorded + (let [events (d/q '{:find [?type ?nick ?chan-name] + :where [[?e :papod.event/type ?type] + [?e :papod.event/source-nick ?nick] + [?e :papod.event/channel ?c] + [?c :papod.channel/name ?chan-name]]} + db)] + (is (= #{["user-join" "alice" "#test"]} events)))) + ;; In-memory updated + (is (= #{"alice"} (get @(:channels components) "#test"))) + ;; Notification returned in replies + (is (some #(string/includes? % "JOIN #test") replies)))) + (testing "second JOIN reuses existing channel" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {})) + conn (:conn components)] + (handle-join ["#test"] (registered-client "alice" alice-out test-network-id) components) + (handle-join ["#test"] (registered-client "bob" bob-out test-network-id) components) + ;; Still one channel entity + (let [db (d/db conn) + channels (d/q '{:find [?name] + :where [[?c :papod.channel/name ?name]]} + db)] + (is (= 1 (count channels)))) + ;; Two memberships + (let [db (d/db conn) + memberships (d/q '{:find [?nick] + :where [[?m :papod.membership/nick ?nick]]} + db)] + (is (= #{["alice"] ["bob"]} memberships))))) + (testing "PRIVMSG error cases" + (let [c (registered-client "alice" (java.io.ByteArrayOutputStream.))] + (is (string/includes? + (first (handle-privmsg [] nil c no-conn)) + "411")) + (is (string/includes? + (first (handle-privmsg ["bob"] nil c no-conn)) + "412")))) + (testing "PRIVMSG to nonexistent user returns 401" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (assoc (test-components) + :clients (atom {"alice" {:w (java.io.ByteArrayOutputStream.)}}) + :channels (atom {}))] + (let [replies (handle-privmsg ["nobody" ":hello"] + nil c components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "401"))))) + (testing "NOTICE delivers to channel without error replies" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + components + (assoc (test-components) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}})) + alice (registered-client "alice" alice-out)] + (@#'papod/handle-notice ["#test" ":hi"] alice components) + (is (string/includes? (.toString bob-out "UTF-8") + "NOTICE #test :hi")) + (is (= "" (.toString alice-out "UTF-8"))))) + (testing "NOTICE to nonexistent channel generates no error" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (assoc (test-components) + :clients (atom {}) + :channels (atom {}))] + (is (empty? (@#'papod/handle-notice + ["#nope" ":hi"] c components)))))) + +(deftest test_nickserv + (testing "REGISTER creates user in cracha" + (let [out (java.io.ByteArrayOutputStream.) + c (registered-client "alice" out) + components (test-components) + conn (:conn components) + replies (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "registered successfully")) + (let [cc (:conn (:cracha components)) + user-id (cracha/user-by-email cc "alice")] + (is (some? user-id)) + (is (cracha/user-confirmed? cc user-id))))) + (testing "REGISTER rejects duplicate nick" + (let [out (java.io.ByteArrayOutputStream.) + c (registered-client "alice" out) + components (test-components)] + (handle-privmsg ["NickServ" ":REGISTER pass1"] nil c components) + (let [replies (handle-privmsg ["NickServ" ":REGISTER pass2"] nil c components)] + (is (string/includes? (first replies) "already registered"))))) + (testing "IDENTIFY succeeds with correct password" + (let [out (java.io.ByteArrayOutputStream.) + c (registered-client "alice" out) + components (test-components)] + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) + (let [replies (handle-privmsg ["NickServ" ":IDENTIFY mypass"] nil c components)] + (is (string/includes? (first replies) "now identified")) + (is (:identified? @c))))) + (testing "IDENTIFY fails with wrong password" + (let [out (java.io.ByteArrayOutputStream.) + c (registered-client "alice" out) + components (test-components)] + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) + (let [replies (handle-privmsg ["NickServ" ":IDENTIFY wrong"] nil c components)] + (is (string/includes? (first replies) "Invalid password")) + (is (not (:identified? @c)))))) + (testing "IDENTIFY fails for unregistered nick" + (let [out (java.io.ByteArrayOutputStream.) + c (registered-client "alice" out) + components (test-components) + replies (handle-privmsg ["NickServ" ":IDENTIFY mypass"] nil c components)] + (is (string/includes? (first replies) "not registered")))) + (testing "NickServ messages are NOT persisted" + (let [out (java.io.ByteArrayOutputStream.) + c (registered-client "alice" out) + components (test-components) + conn (:conn components)] + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) + (let [db (d/db conn) + evts (d/q '{:find [?e] + :where [[?e :papod.event/id _]]} + db)] + (is (zero? (count evts))))))) + +(defn b64 + [s] + (.encodeToString (java.util.Base64/getEncoder) (.getBytes s "UTF-8"))) + +(deftest test_sasl + (testing "full CAP + SASL PLAIN flow" + (let [out (java.io.ByteArrayOutputStream.) + c (client) + components (test-components)] + ;; Register user in cracha first + (swap! c assoc :nick "alice" :registered? true :w out) + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) + ;; Reset client for fresh connection + (let [c2 (atom {:nick nil :user nil :pass nil :registered? false + :w out})] + ;; CAP LS suspends registration + (let [replies (handle-cap ["LS" "302"] c2 components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "sasl=PLAIN")) + (is (:cap-negotiating? @c2))) + ;; CAP REQ sasl + (let [replies (handle-cap ["REQ" ":sasl"] c2 components)] + (is (string/includes? (first replies) "ACK")) + (is (contains? (:caps @c2) "sasl"))) + ;; NICK + USER during negotiation do not trigger registration + (swap! c2 assoc :nick "alice") + (let [replies (replies-for! {:command "USER" :params ["" "alice" "0" "x" ":A"]} + c2 components)] + (is (empty? replies)) + (is (not (:registered? @c2)))) + ;; AUTHENTICATE PLAIN + (let [replies (handle-authenticate ["PLAIN"] c2 components)] + (is (= ["AUTHENTICATE +"] replies))) + ;; Send credentials + (let [creds (b64 "\u0000alice\u0000mypass") + replies (handle-authenticate [creds] c2 components)] + (is (= 2 (count replies))) + (is (string/includes? (first replies) "900")) + (is (string/includes? (second replies) "903")) + (is (:authenticated? @c2))) + ;; CAP END completes registration + (let [replies (handle-cap ["END"] c2 components)] + ;; 001, 002, 003, 004, three RPL_005 lines, 422. + (is (= 8 (count replies))) + (is (string/includes? (first replies) "001")) + (is (:registered? @c2)))))) + (testing "SASL with wrong password" + (let [out (java.io.ByteArrayOutputStream.) + c (client) + components (test-components)] + ;; Register user + (swap! c assoc :nick "bob" :registered? true :w out) + (handle-privmsg ["NickServ" ":REGISTER secret"] nil c components) + ;; Fresh connection + (let [c2 (atom {:nick "bob" :user nil :pass nil :registered? false + :w out :caps #{"sasl"}})] + (handle-authenticate ["PLAIN"] c2 components) + (let [creds (b64 "\u0000bob\u0000wrong") + replies (handle-authenticate [creds] c2 components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "904")) + (is (not (:authenticated? @c2))))))) + (testing "AUTHENTICATE without CAP sasl" + (let [c (atom {:nick "x" :caps #{}})] + (is (string/includes? + (first (handle-authenticate ["PLAIN"] c no-conn)) + "904")))) + (testing "AUTHENTICATE abort" + (let [c (atom {:nick "x" :caps #{"sasl"} :sasl-state :authenticating})] + (is (string/includes? + (first (handle-authenticate ["*"] c no-conn)) + "906")))) + (testing "AUTHENTICATE unsupported mechanism" + (let [c (atom {:nick "x" :caps #{"sasl"}})] + (let [replies (handle-authenticate ["SCRAM-SHA-1"] c no-conn)] + (is (string/includes? (first replies) "908")) + (is (string/includes? (second replies) "904"))))) + (testing "CAP REQ unsupported capability" + (let [c (atom {:nick "x" :cap-negotiating? true})] + (is (string/includes? + (first (handle-cap ["REQ" ":unknown-cap"] c no-conn)) + "NAK")))) + (testing "re-authenticate after success" + (let [c (atom {:nick "x" :caps #{"sasl"} :authenticated? true})] + ;; SASL 3.2 allows re-auth: server should respond + ;; with "AUTHENTICATE +" and not 907. + (is (= "AUTHENTICATE +" + (first (handle-authenticate ["PLAIN"] c + no-conn))))))) + +(deftest test_chanserv + (testing "REGISTER + INFO + SET" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + out (java.io.ByteArrayOutputStream.) + c (registered-client "alice" out test-network-id)] + (handle-join ["#test"] c + (assoc components + :clients (atom {"alice" {:w out}}) + :channels (atom {}))) + (let [replies (handle-privmsg ["ChanServ" ":REGISTER #test"] + c components)] + (is (string/includes? (first replies) "has been registered"))) + (let [replies (handle-privmsg ["ChanServ" ":INFO #test"] + c components)] + (is (some #(string/includes? % "alice") replies))) + (let [replies (handle-privmsg ["ChanServ" ":SET #test TOPIC Hello world"] + c components)] + (is (string/includes? (first replies) "Topic"))) + (let [replies (handle-privmsg ["ChanServ" ":INFO #test"] + c components)] + (is (some #(string/includes? % "Hello world") replies))))) + (testing "OP and DEOP" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id)] + (handle-join ["#test"] alice + (assoc components + :clients (atom {"alice" {:w out}}) + :channels (atom {}))) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) + (let [replies (handle-privmsg ["ChanServ" ":OP #test bob"] + alice components)] + (is (string/includes? (first replies) "now an operator"))) + (let [replies (handle-privmsg ["ChanServ" ":DEOP #test bob"] + alice components)] + (is (string/includes? (first replies) "no longer an operator"))))) + (testing "KICK and BAN" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}})) + alice (registered-client "alice" alice-out test-network-id) + bob (registered-client "bob" bob-out test-network-id)] + (handle-join ["#test"] alice components) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) + (let [replies (handle-privmsg ["ChanServ" ":KICK #test bob bad behavior"] + alice components)] + (is (empty? replies)) + (is (not (contains? (get @(:channels components) "#test") "bob"))) + (is (string/includes? (.toString bob-out "UTF-8") "KICK"))) + (let [replies (handle-privmsg ["ChanServ" ":BAN #test bob"] + alice components)] + (is (string/includes? (first replies) "banned"))) + (let [replies (handle-join ["#test"] bob components)] + (is (string/includes? (first replies) "474"))))) + (testing "permission denied for non-op" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id) + bob (registered-client "bob" out test-network-id)] + (handle-join ["#test"] alice + (assoc components + :clients (atom {"alice" {:w out}}) + :channels (atom {}))) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) + (let [replies (handle-privmsg ["ChanServ" ":SET #test TOPIC nope"] + bob components)] + (is (string/includes? (first replies) "Permission denied")))))) + +(deftest test_memoserv + (testing "SEND + LIST + READ + DELETE" + (let [components (test-components) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out) + bob (registered-client "bob" out)] + ;; Send memo + (let [replies (handle-privmsg ["MemoServ" ":SEND bob Hello Bob!"] + alice components)] + (is (string/includes? (first replies) "Memo sent"))) + ;; List as bob + (let [replies (handle-privmsg ["MemoServ" ":LIST"] nil bob components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "alice")) + (is (string/includes? (first replies) "unread")) + ;; Extract memo ID for READ/DELETE + (let [memo-id (last (string/split (first replies) #" "))] + ;; Read + (let [replies (handle-privmsg + ["MemoServ" (str ":READ " memo-id)] + bob components)] + (is (string/includes? (first replies) "Hello Bob!"))) + ;; Now listed as read + (let [replies (handle-privmsg ["MemoServ" ":LIST"] nil bob components)] + (is (string/includes? (first replies) "read"))) + ;; Delete + (let [replies (handle-privmsg + ["MemoServ" (str ":DELETE " memo-id)] + bob components)] + (is (string/includes? (first replies) "deleted"))) + ;; List empty + (let [replies (handle-privmsg ["MemoServ" ":LIST"] nil bob components)] + (is (string/includes? (first replies) "No memos"))))))) + (testing "auto-delivery on registration" + (let [components (dissoc (test-components) :cracha) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out)] + ;; Send memo to bob while offline + (handle-privmsg ["MemoServ" ":SEND bob Hey!"] nil alice components) + ;; Bob registers (no cracha = auth skipped) + (let [c (atom {:nick nil :user nil :pass nil :registered? false + :w out})] + (swap! c assoc :nick "bob") + (let [replies (@#'papod/replies-for! + {:command "USER" :params ["" "bob" "0" "x" ":B"]} + c components)] + ;; 8 welcome lines (001..422 with three RPL_005) + memo. + (is (= 9 (count replies))) + (is (string/includes? (first replies) "001")) + (is (string/includes? (last replies) "MemoServ"))))))) + +(deftest test_schema-invariants + (testing "events get monotonic sequence numbers per channel" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {})) + conn (:conn components) + alice (registered-client "alice" alice-out test-network-id)] + (handle-join ["#test"] alice components) + (swap! (:channels components) update "#test" conj "bob") + (handle-privmsg ["#test" ":first"] nil alice components) + (handle-privmsg ["#test" ":second"] nil alice components) + (let [db (d/db conn) + seqs (sort (map first + (d/q '{:find [?seq] + :in [$ ?chan-name] + :where [[?e :papod.event/seq ?seq] + [?e :papod.event/channel ?c] + [?c :papod.channel/name ?chan-name]]} + db "#test")))] + (is (= [1 2 3] seqs))))) + (testing "duplicate access entry is rejected" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + conn (:conn components) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id)] + (handle-join ["#test"] alice + (assoc components + :clients (atom {"alice" {:w out}}) + :channels (atom {}))) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) + (handle-privmsg ["ChanServ" ":OP #test bob"] nil alice components) + ;; Second OP for same nick is caught by has-access? check + (let [replies (handle-privmsg ["ChanServ" ":OP #test bob"] + alice components)] + (is (string/includes? (first replies) "already an operator"))))) + (testing "non-empty predicates reject empty strings" + (let [components (test-components) + conn (:conn components)] + (is (thrown? java.util.concurrent.ExecutionException + @(d/transact conn + [{:db/ensure :papod.memo/attrs + :papod.memo/id (java.util.UUID/randomUUID) + :papod.memo/from "" + :papod.memo/to "bob" + :papod.memo/content "hi" + :papod.memo/created-at (java.util.Date.) + :papod.memo/read? false}]))))) + (testing "entity attrs enforce required fields" + (let [components (test-components) + conn (:conn components)] + (is (thrown? java.util.concurrent.ExecutionException + @(d/transact conn + [{:db/ensure :papod.memo/attrs + :papod.memo/id (java.util.UUID/randomUUID) + :papod.memo/from "alice"}])))))) + +(deftest test_private-channels + (testing "private channel created via ChanServ is joinable by & handle" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {})) + alice (registered-client "alice" alice-out test-network-id) + bob (registered-client "bob" bob-out test-network-id) + conn (:conn components) + chan-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.channel/attrs + :db/id "new-channel" + :papod.channel/id chan-id + :papod.channel/network [:papod.network/id test-network-id] + :papod.channel/type :papod.channel.type/private + :papod.channel/label "secret-project" + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)} + {:db/ensure :papod.access/attrs + :papod.access/id (java.util.UUID/randomUUID) + :papod.access/channel "new-channel" + :papod.access/nick "alice" + :papod.access/level "owner"}]) + handle (str "&" chan-id)] + ;; Alice (owner) can join + (let [replies (handle-join [handle] alice components)] + (is (some #(string/includes? % "JOIN") replies)) + (is (contains? (get @(:channels components) handle) "alice"))) + ;; Bob (no access) cannot join — gets "No such channel" + (let [replies (handle-join [handle] bob components)] + (is (string/includes? (first replies) "403"))))) + (testing "probing #private-name returns no such channel" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + conn (:conn components) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id) + _ @(d/transact conn + [{:db/ensure :papod.channel/attrs + :papod.channel/id (java.util.UUID/randomUUID) + :papod.channel/network [:papod.network/id test-network-id] + :papod.channel/type :papod.channel.type/private + :papod.channel/label "bob-firing-wg" + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}])] + ;; Probing by name fails — no public name exists + (let [replies (handle-join ["#bob-firing-wg"] alice + (assoc components :channels (atom {})))] + ;; Creates a NEW public channel named #bob-firing-wg + ;; (doesn't find the private one — that's the point) + (is (some #(string/includes? % "JOIN") replies))))) + (testing "#-prefixed UUID name works as a normal public channel" + (let [{:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {}) + :channels (atom {})) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id) + uuid-name (str "#" (java.util.UUID/randomUUID))] + ;; JOIN with a UUID-shaped name — treated as a public channel name + (let [c (assoc components + :clients (atom {"alice" {:w out}}))] + (let [replies (handle-join [uuid-name] alice c)] + (is (some #(string/includes? % "JOIN") replies)) + (is (contains? (get @(:channels c) uuid-name) "alice")))))) + (testing "&non-uuid is rejected" + (let [{:keys [test-network-id] :as components} + (assoc (test-components-with-network) :channels (atom {})) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id)] + (let [replies (handle-join ["¬-a-uuid"] alice components)] + (is (string/includes? (first replies) "403")))))) + +(deftest test_networks-and-members + (testing "registration creates member in connection's network" + (let [{:keys [test-network-id] :as components} + (dissoc (test-components-with-network) :cracha) + conn (:conn components) + out (java.io.ByteArrayOutputStream.) + c (atom {:nick nil :user nil :pass nil :registered? false + :w out :network-id test-network-id})] + (swap! c assoc :nick "alice") + (@#'papod/replies-for! + {:command "USER" :params ["" "alice" "0" "x" ":A"]} + c components) + (is (:registered? @c)) + (let [db (d/db conn) + members (d/q '{:find [?nick ?status] + :in [$ ?net] + :where [[?m :papod.member/network ?net] + [?m :papod.member/nick ?nick] + [?m :papod.member/status ?status]]} + db [:papod.network/id test-network-id])] + (is (= #{["alice" "active"]} members))))) + (testing "no member created without NETWORK command" + (let [components (dissoc (test-components-with-network) :cracha) + conn (:conn components) + out (java.io.ByteArrayOutputStream.) + c (atom {:nick nil :user nil :pass nil :registered? false + :w out})] + (swap! c assoc :nick "bob") + (@#'papod/replies-for! + {:command "USER" :params ["" "bob" "0" "x" ":B"]} + c components) + (is (:registered? @c)) + (let [db (d/db conn) + members (d/q '{:find [?nick] + :where [[?m :papod.member/nick ?nick]]} + db)] + (is (empty? members))))) + (testing "channels are scoped to network" + (let [{:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {}) + :channels (atom {})) + conn (:conn components) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id)] + (handle-join ["#general"] alice + (assoc components :clients (atom {"alice" {:w out}}))) + (let [db (d/db conn) + chans (d/q '{:find [?name ?net-id] + :in [$ ?net] + :where [[?c :papod.channel/name ?name] + [?c :papod.channel/network ?net] + [?net :papod.network/id ?net-id]]} + db [:papod.network/id test-network-id])] + (is (= 1 (count chans))) + (is (= "#general" (ffirst chans))) + (is (= test-network-id (second (first chans))))))) + (testing "network has required attrs" + (let [conn (:conn (test-components))] + (is (thrown? java.util.concurrent.ExecutionException + @(d/transact conn + [{:db/ensure :papod.network/attrs + :papod.network/id (java.util.UUID/randomUUID) + :papod.network/name "incomplete"}])))))) + +(deftest test_session-tracking + (testing "SASL auth creates session + logon" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + out (java.io.ByteArrayOutputStream.) + c (client)] + ;; Register user in cracha first + (swap! c assoc :nick "alice" :registered? true :w out + :network-id test-network-id) + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) + ;; Fresh connection with connection-id (simulating client-loop!) + (let [conn-id (java.util.UUID/randomUUID) + conn (:conn components) + _ @(d/transact conn + [{:db/ensure :papod.connection/attrs + :papod.connection/id conn-id + :papod.connection/process [:papod.process/id + (:process-id components)] + :papod.connection/created-at (java.util.Date.)}]) + c2 (atom {:nick "alice" :caps #{"sasl"} + :w out :connection-id conn-id})] + (handle-authenticate ["PLAIN"] c2 components) + (let [creds (b64 "\u0000alice\u0000mypass") + replies (handle-authenticate [creds] c2 components)] + (is (string/includes? (first replies) "900")) + (is (some? (:session-id @c2))) + ;; Verify session in DB + (let [db (d/db conn) + sessions (d/q '{:find [?nick] + :where [[?s :papod.session/nick ?nick]]} + db)] + (is (= #{["alice"]} sessions))) + ;; Verify logon in DB + (let [db (d/db conn) + logons (d/q '{:find [?sid ?cid] + :where [[?l :papod.logon/session ?s] + [?l :papod.logon/connection ?c] + [?s :papod.session/id ?sid] + [?c :papod.connection/id ?cid]]} + db)] + (is (= 1 (count logons))) + (is (= conn-id (second (first logons))))))))) + (testing "NickServ IDENTIFY creates session + logon" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + out (java.io.ByteArrayOutputStream.) + conn (:conn components) + conn-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.connection/attrs + :papod.connection/id conn-id + :papod.connection/process [:papod.process/id + (:process-id components)] + :papod.connection/created-at (java.util.Date.)}]) + c (registered-client "bob" out test-network-id)] + (swap! c assoc :connection-id conn-id) + ;; Register user + (handle-privmsg ["NickServ" ":REGISTER secret"] nil c components) + ;; Identify + (let [replies (handle-privmsg ["NickServ" ":IDENTIFY secret"] + c components)] + (is (string/includes? (first replies) "now identified")) + (is (some? (:session-id @c))) + ;; Session + logon in DB + (let [db (d/db conn) + logons (d/q '{:find [?nick ?cid] + :where [[?l :papod.logon/session ?s] + [?l :papod.logon/connection ?c] + [?s :papod.session/nick ?nick] + [?c :papod.connection/id ?cid]]} + db)] + (is (= 1 (count logons))) + (is (= ["bob" conn-id] (vec (first logons))))))) + (testing "SASL RESUME resumes existing session" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + out (java.io.ByteArrayOutputStream.) + conn (:conn components) + ;; First: register + auth to create a session + conn-id1 (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.connection/attrs + :papod.connection/id conn-id1 + :papod.connection/process [:papod.process/id + (:process-id components)] + :papod.connection/created-at (java.util.Date.)}]) + c1 (atom {:nick "alice" :registered? true :w out + :caps #{"sasl"} :connection-id conn-id1 + :network-id test-network-id})] + ;; Register user in cracha + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c1 components) + ;; Auth via SASL PLAIN to get session + (handle-authenticate ["PLAIN"] c1 components) + (handle-authenticate [(b64 "\u0000alice\u0000mypass")] c1 components) + (let [session-id (:session-id @c1)] + (is (some? session-id)) + ;; Now simulate reconnect: new connection, RESUME with session ID + (let [conn-id2 (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.connection/attrs + :papod.connection/id conn-id2 + :papod.connection/process [:papod.process/id + (:process-id components)] + :papod.connection/created-at (java.util.Date.)}]) + c2 (atom {:nick "alice" :w out + :caps #{"sasl"} :connection-id conn-id2})] + (handle-authenticate ["RESUME"] c2 components) + (let [replies (handle-authenticate + [(b64 (str session-id))] c2 components)] + (is (string/includes? (first replies) "900")) + (is (:authenticated? @c2)) + (is (= session-id (:session-id @c2))) + ;; Two logons for the same session (two connections) + (let [db (d/db conn) + logons (d/q '{:find [?cid] + :in [$ ?sid] + :where [[?l :papod.logon/session ?s] + [?l :papod.logon/connection ?c] + [?s :papod.session/id ?sid] + [?c :papod.connection/id ?cid]]} + db session-id)] + (is (= 2 (count logons))) + (is (= #{[conn-id1] [conn-id2]} logons)))))))) + (testing "SASL RESUME fails with wrong nick" + (let [components (test-components-with-network) + out (java.io.ByteArrayOutputStream.) + conn (:conn components) + conn-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.connection/attrs + :papod.connection/id conn-id + :papod.connection/process [:papod.process/id + (:process-id components)] + :papod.connection/created-at (java.util.Date.)}]) + ;; Create a session for "alice" + session-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.session/attrs + :papod.session/id session-id + :papod.session/nick "alice" + :papod.session/created-at (java.util.Date.)}]) + ;; Try to resume as "bob" + c (atom {:nick "bob" :w out :caps #{"sasl"} + :connection-id conn-id})] + (handle-authenticate ["RESUME"] c components) + (let [replies (handle-authenticate + [(b64 (str session-id))] c components)] + (is (string/includes? (first replies) "904")) + (is (not (:authenticated? @c)))))))) + +(def handle-topic @#'papod/handle-topic) +(def handle-kick @#'papod/handle-kick) +(def handle-chathistory @#'papod/handle-chathistory) +(def handle-redact @#'papod/handle-redact) +(def handle-edit @#'papod/handle-edit) +(def handle-tagmsg @#'papod/handle-tagmsg) + +(deftest test_whois-who-mode + (testing "WHOIS returns user info" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" alice-out) + bob (registered-client "bob" bob-out) + components + (assoc (test-components) + :clients (atom {"alice" {:w alice-out + :client-atom alice} + "bob" {:w bob-out + :client-atom bob}}) + :channels (atom {}))] + (let [replies (replies-for! + {:command "WHOIS" :params ["" "bob"]} + alice components)] + (is (some #(string/includes? % "311") replies)) + (is (some #(string/includes? % "bob") replies)) + (is (some #(string/includes? % "318") replies))))) + (testing "WHOIS shows away status" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" alice-out) + bob (registered-client "bob" bob-out) + _ (swap! bob assoc :away "gone fishing") + components + (assoc (test-components) + :clients (atom {"alice" {:w alice-out + :client-atom alice} + "bob" {:w bob-out + :client-atom bob}}) + :channels (atom {}))] + (let [replies (replies-for! + {:command "WHOIS" :params ["" "bob"]} + alice components)] + (is (some #(string/includes? % "301") replies)) + (is (some #(string/includes? % "gone fishing") + replies))))) + (testing "WHOIS for nonexistent user" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (assoc (test-components) + :clients (atom {}) + :channels (atom {}))] + (let [replies (replies-for! + {:command "WHOIS" :params ["" "nobody"]} + c components)] + (is (some #(string/includes? % "401") replies)) + (is (some #(string/includes? % "318") replies))))) + (testing "WHO #channel lists members" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" alice-out) + bob (registered-client "bob" bob-out) + components + (assoc (test-components) + :clients (atom {"alice" {:w alice-out + :client-atom alice} + "bob" {:w bob-out + :client-atom bob}}) + :channels (atom {"#test" #{"alice" "bob"}}))] + (let [replies (replies-for! + {:command "WHO" :params ["" "#test"]} + alice components)] + ;; One 352 per member + one 315 + (is (= 2 (count (filter + #(string/includes? % " 352 ") + replies)))) + (is (some #(string/includes? % "315") replies))))) + (testing "WHO nonexistent nick" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (assoc (test-components) + :clients (atom {}) + :channels (atom {}))] + (let [replies (replies-for! + {:command "WHO" :params ["" "nobody"]} + c components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "315"))))) + (testing "MODE user returns 221" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (assoc (test-components) + :clients (atom {}) + :channels (atom {}))] + (let [replies (replies-for! + {:command "MODE" :params ["" "alice"]} + c components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "221"))))) + (testing "MODE #channel returns 324 + 329" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (assoc (test-components) + :clients (atom {}) + :channels (atom {}))] + (let [replies (replies-for! + {:command "MODE" + :params ["" "#test"]} + c components)] + (is (= 2 (count replies))) + (is (string/includes? (first replies) "324")) + (is (string/includes? (second replies) "329"))))) + (testing "USERHOST returns 302 with away indicator" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" alice-out) + bob (registered-client "bob" bob-out) + _ (swap! bob assoc :away "brb") + components + (assoc (test-components) + :clients (atom {"alice" {:w alice-out + :client-atom alice} + "bob" {:w bob-out + :client-atom bob}}) + :channels (atom {}))] + (let [replies (replies-for! + {:command "USERHOST" + :params ["" "alice" "bob"]} + alice components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "302")) + (is (string/includes? (first replies) "alice=+")) + (is (string/includes? (first replies) "bob=-")))))) + +(deftest test_topic + (testing "TOPIC query with no topic" + (let [{:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {}) :channels (atom {})) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id) + components (assoc components + :clients (atom {"alice" {:w out}}))] + (handle-join ["#test"] alice components) + (let [replies (handle-topic ["#test"] alice components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "331"))))) + (testing "TOPIC set and query" + (let [{:keys [test-network-id] :as components} + (test-components-with-network) + alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" alice-out + test-network-id) + components + (assoc components + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {}))] + (handle-join ["#test"] alice components) + (swap! (:channels components) update "#test" conj "bob") + ;; Set topic + (.reset bob-out) + (handle-topic ["#test" ":Hello World"] alice components) + ;; Bob receives TOPIC broadcast + (is (string/includes? (.toString bob-out "UTF-8") + "TOPIC #test :Hello World")) + ;; Query returns 332 + (let [replies (handle-topic ["#test"] alice components)] + (is (string/includes? (first replies) "332")) + (is (string/includes? (first replies) "Hello World"))))) + (testing "TOPIC not on channel" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (assoc (test-components) + :channels (atom {"#test" #{"bob"}}))] + (let [replies (handle-topic ["#test"] c components)] + (is (string/includes? (first replies) "442"))))) + (testing "TOPIC no params" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.))] + (let [replies (handle-topic [] c no-conn)] + (is (string/includes? (first replies) "461")))))) + +(deftest test_kick-irc + (testing "KICK removes user and notifies" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + baz-out (java.io.ByteArrayOutputStream.) + components + (assoc (test-components) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out} + "baz" {:w baz-out}}) + :channels (atom {"#test" #{"alice" "bob" "baz"}}) + :ops (atom {"#test" #{"alice"}})) + alice (registered-client "alice" alice-out)] + (handle-kick ["#test" "bob" ":bye!"] alice components) + ;; bob removed from channel + (is (not (contains? (get @(:channels components) "#test") + "bob"))) + ;; All three get KICK notification + (is (string/includes? (.toString alice-out "UTF-8") + "KICK #test bob")) + (is (string/includes? (.toString bob-out "UTF-8") + "KICK #test bob :bye!")) + (is (string/includes? (.toString baz-out "UTF-8") + "KICK #test bob")))) + (testing "KICK default reason is kicker's nick" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + components + (assoc (test-components) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}}) + :ops (atom {"#test" #{"alice"}})) + alice (registered-client "alice" alice-out)] + (handle-kick ["#test" "bob"] alice components) + (is (string/includes? (.toString bob-out "UTF-8") + "KICK #test bob :alice")))) + (testing "KICK nonexistent channel" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (assoc (test-components) + :clients (atom {}) + :channels (atom {}))] + (let [replies (handle-kick ["#nope" "bob"] c components)] + (is (string/includes? (first replies) "403"))))) + (testing "KICK not on channel" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (assoc (test-components) + :channels (atom {"#test" #{"bob"}}))] + (let [replies (handle-kick ["#test" "bob"] c components)] + (is (string/includes? (first replies) "442"))))) + (testing "KICK target not on channel" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (assoc (test-components) + :channels + (atom {"#test" #{"alice"}}))] + (let [replies (handle-kick ["#test" "bob"] c components)] + (is (string/includes? (first replies) "441"))))) + (testing "KICK no params" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.))] + (let [replies (handle-kick [] c no-conn)] + (is (string/includes? (first replies) "461")))))) + +(deftest test_away-rpl + (testing "PRIVMSG to away user returns RPL_AWAY" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" alice-out) + bob (registered-client "bob" bob-out) + _ (swap! bob assoc :away "on vacation") + components + (assoc (test-components) + :clients (atom {"alice" {:w alice-out + :client-atom alice} + "bob" {:w bob-out + :client-atom bob}}) + :channels (atom {}))] + (let [replies (handle-privmsg ["bob" ":hey"] + nil alice components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "301")) + (is (string/includes? (first replies) "on vacation")) + (is (string/includes? (first replies) "bob"))))) + (testing "PRIVMSG to non-away user returns no RPL_AWAY" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" alice-out) + bob (registered-client "bob" bob-out) + components + (assoc (test-components) + :clients (atom {"alice" {:w alice-out + :client-atom alice} + "bob" {:w bob-out + :client-atom bob}}) + :channels (atom {}))] + (is (empty? (handle-privmsg ["bob" ":hey"] + nil alice components)))))) + +(deftest test_notice-dm + (testing "NOTICE delivers to user" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + components + (assoc (test-components) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {})) + alice (registered-client "alice" alice-out)] + (@#'papod/handle-notice ["bob" ":hi there"] + alice components) + (is (string/includes? (.toString bob-out "UTF-8") + "NOTICE bob :hi there")) + (is (= "" (.toString alice-out "UTF-8")))))) + +(deftest test_privmsg-empty-trailing + (testing "PRIVMSG #chan : returns 412" + (let [{:keys [test-network-id] :as components} + (test-components-with-network) + out (java.io.ByteArrayOutputStream.) + c (registered-client "alice" out test-network-id) + components (assoc components + :clients (atom {"alice" {:w out}}) + :channels (atom {}))] + (handle-join ["#test"] c components) + (let [replies (replies-for! + {:command "PRIVMSG" + :params ["" "#test" ":"]} + c components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "412")))))) + +(deftest test_join-comma-separated + (testing "JOIN #a,#b creates two channels" + (let [{:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {}) :channels (atom {})) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id) + components (assoc components + :clients (atom {"alice" {:w out}}))] + (handle-join ["#a,#b"] alice components) + (is (contains? (get @(:channels components) "#a") + "alice")) + (is (contains? (get @(:channels components) "#b") + "alice")))) + (testing "JOIN #valid,invalid rejects invalid" + (let [{:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {}) :channels (atom {})) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id) + components (assoc components + :clients (atom {"alice" {:w out}}))] + (let [replies (handle-join ["#ok,nope"] alice + components)] + ;; #ok should succeed, nope should get 403 + (is (some #(string/includes? % "403") replies)) + (is (contains? (get @(:channels components) "#ok") + "alice")))))) + +(deftest test_names-list + (testing "NAMES on nonexistent channel" + (let [{:keys [test-network-id] :as components} + (test-components-with-network) + c (registered-client "alice" + (java.io.ByteArrayOutputStream.) + test-network-id)] + (let [replies (replies-for! + {:command "NAMES" + :params ["" "#nope"]} + c components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "366"))))) + (testing "LIST returns end-of-list" + (let [{:keys [test-network-id] :as components} + (test-components-with-network) + c (registered-client "alice" + (java.io.ByteArrayOutputStream.) + test-network-id)] + (let [replies (replies-for! + {:command "LIST" :params []} + c components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "323")))))) + +(deftest test_messaging-features + (testing "messages include msgid tags" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + bob (registered-client "bob" bob-out) + _ (swap! bob assoc + :caps #{"message-tags" "server-time"}) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out + :client-atom bob}}) + :channels (atom {"#test" #{"alice" "bob"}})) + conn (:conn components) + alice (registered-client "alice" alice-out + test-network-id)] + ;; Create channel in DB + @(d/transact conn + [{:papod.channel/id (java.util.UUID/randomUUID) + :papod.channel/network [:papod.network/id test-network-id] + :papod.channel/name "#test" + :papod.channel/type :papod.channel.type/public + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}]) + (handle-privmsg ["#test" ":hello"] alice components) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "@msgid=")) + (is (string/includes? delivered "PRIVMSG #test :hello"))))) + (testing "CHATHISTORY returns events by seq" + (let [alice-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out}}) + :channels (atom {})) + alice (registered-client "alice" alice-out test-network-id)] + (handle-join ["#test"] alice components) + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":msg1"]} + alice components) + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":msg2"]} + alice components) + (let [replies (handle-chathistory + ["LATEST" "#test" "*" "10"] + alice components)] + ;; BATCH +id chathistory #test ... BATCH -id + (is (string/includes? (first replies) "BATCH +")) + (is (string/includes? (first replies) "chathistory")) + (is (some #(string/includes? % "msg1") replies)) + (is (some #(string/includes? % "msg2") replies)) + (is (string/includes? (last replies) "BATCH -"))))) + (testing "EDIT persists and notifies (draft/message-editing)" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {})) + conn (:conn components) + alice (registered-client "alice" alice-out test-network-id)] + (handle-join ["#test"] alice components) + (swap! (:channels components) update "#test" conj "bob") + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":original"]} + alice components) + (let [db (d/db conn) + msg-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + (handle-edit ["#test" (str msg-id) ":corrected"] alice components) + ;; Edit event persisted with edit-of ref + (let [edits (d/q '{:find [?payload] + :where [[?e :papod.event/type "user-edit"] + [?e :papod.event/edit-of ?orig] + [?e :papod.event/payload ?payload]]} + (d/db conn))] + (is (= 1 (count edits))) + (is (= ":corrected" (ffirst edits)))) + ;; Bob receives EDIT command (not custom tag) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "EDIT #test")) + (is (string/includes? delivered "corrected")) + (is (string/includes? delivered "@msgid="))) + ;; Non-author can't edit + (let [bob (registered-client "bob" bob-out test-network-id) + replies (handle-edit ["#test" (str msg-id) ":nope"] + bob components)] + (is (string/includes? (first replies) "EDIT_FORBIDDEN")))))) + (testing "REDACT deletes and notifies (draft/message-redaction)" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + bob-atom (atom {:caps #{"draft/message-redaction"}}) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out + :client-atom bob-atom}}) + :channels (atom {})) + conn (:conn components) + alice (registered-client "alice" alice-out test-network-id)] + (handle-join ["#test"] alice components) + (swap! (:channels components) update "#test" conj "bob") + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":to-delete"]} + alice components) + (let [db (d/db conn) + msg-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + (handle-redact ["#test" (str msg-id) "oops"] alice components) + (let [deletes (d/q '{:find [?id] + :where [[?e :papod.event/type "user-delete"] + [?e :papod.event/delete-of ?orig] + [?e :papod.event/id ?id]]} + (d/db conn))] + (is (= 1 (count deletes)))) + ;; Bob receives REDACT (not custom +papod/delete tag) + (is (string/includes? (.toString bob-out "UTF-8") "REDACT"))))) + (testing "+draft/react via TAGMSG" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {})) + conn (:conn components) + alice (registered-client "alice" alice-out test-network-id)] + (handle-join ["#test"] alice components) + (swap! (:channels components) update "#test" conj "bob") + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":reactable"]} + alice components) + (let [db (d/db conn) + msg-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + ;; IRCv3: @+reply=<msgid>;+draft/react=<emoji> TAGMSG #channel + (handle-tagmsg {:tags {"+reply" (str msg-id) + "+draft/react" "thumbsup"} + :command "TAGMSG" + :params ["" "#test"]} + alice components) + (let [reactions (d/q '{:find [?nick ?emoji] + :where [[?r :papod.reaction/nick ?nick] + [?r :papod.reaction/emoji ?emoji]]} + (d/db conn))] + (is (= #{["alice" "thumbsup"]} reactions))) + (is (string/includes? (.toString bob-out "UTF-8") "+draft/react="))))) + (testing "+reply tag on PRIVMSG creates thread" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + bob (registered-client "bob" bob-out) + _ (swap! bob assoc + :caps #{"message-tags" "server-time"}) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out + :client-atom bob}}) + :channels (atom {})) + conn (:conn components) + alice (registered-client "alice" alice-out + test-network-id)] + (handle-join ["#test"] alice components) + (swap! (:channels components) update "#test" conj "bob") + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":parent message"]} + alice components) + (let [db (d/db conn) + parent-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + ;; IRCv3: @+reply=<msgid> PRIVMSG #channel :reply text + (replies-for! {:tags {"+reply" (str parent-id)} + :command "PRIVMSG" + :params ["" "#test" ":thread reply"]} + alice components) + (let [thread-replies + (d/q '{:find [?payload ?pid] + :where [[?e :papod.event/reply-to ?p] + [?e :papod.event/payload ?payload] + [?p :papod.event/id ?pid]]} + (d/db conn))] + (is (= 1 (count thread-replies))) + (is (= ":thread reply" (ffirst thread-replies))) + (is (= parent-id (second (first thread-replies))))) + (is (string/includes? (.toString bob-out "UTF-8") "+reply=")))))) + +(def handle-markread @#'papod/handle-markread) + +(deftest test_ircv3-capabilities + (testing "server-time tag on outgoing messages" + (let [bob-out (java.io.ByteArrayOutputStream.) + bob (registered-client "bob" bob-out) + _ (swap! bob assoc + :caps #{"message-tags" "server-time"}) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" + {:w (java.io.ByteArrayOutputStream.)} + "bob" + {:w bob-out :client-atom bob}}) + :channels (atom {"#test" #{"alice" "bob"}})) + conn (:conn components) + alice (registered-client "alice" + (java.io.ByteArrayOutputStream.) + test-network-id)] + @(d/transact conn + [{:papod.channel/id (java.util.UUID/randomUUID) + :papod.channel/network [:papod.network/id test-network-id] + :papod.channel/name "#test" + :papod.channel/type :papod.channel.type/public + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}]) + (handle-privmsg ["#test" ":hello"] alice components) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "time=")) + (is (re-find #"time=\d{4}-\d{2}-\d{2}T" + delivered))))) + (testing "echo-message echoes back to sender" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}})) + conn (:conn components) + alice (registered-client "alice" alice-out test-network-id)] + @(d/transact conn + [{:papod.channel/id (java.util.UUID/randomUUID) + :papod.channel/network [:papod.network/id test-network-id] + :papod.channel/name "#test" + :papod.channel/type :papod.channel.type/public + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}]) + ;; Without echo-message: no reply + (let [replies (handle-privmsg ["#test" ":no-echo"] + alice components)] + (is (empty? replies))) + ;; With echo-message + message-tags: reply has @msgid= + (swap! alice assoc :caps + #{"echo-message" "message-tags"}) + (let [replies (handle-privmsg ["#test" ":with-echo"] + alice components)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "with-echo")) + (is (string/includes? (first replies) "@msgid="))))) + (testing "CAP LS advertises all capabilities" + (let [c (client) + replies (handle-cap ["LS" "302"] c no-conn)] + (is (string/includes? (first replies) "server-time")) + (is (string/includes? (first replies) "echo-message")) + (is (string/includes? (first replies) "batch")) + (is (string/includes? (first replies) "labeled-response")) + (is (string/includes? (first replies) "draft/multiline")) + (is (string/includes? (first replies) "draft/read-marker")))) + (testing "typing notification relayed to channel" + (let [alice-out (java.io.ByteArrayOutputStream.) + bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w alice-out} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}})) + alice (registered-client "alice" alice-out test-network-id)] + (handle-tagmsg {:tags {"+typing" "active"} + :command "TAGMSG" + :params ["" "#test"]} + alice components) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "+typing=active"))) + ;; Sender doesn't receive their own typing + (is (= "" (.toString alice-out "UTF-8"))))) + (testing "MARKREAD set and get" + (let [{:keys [test-network-id] :as components} (test-components-with-network) + conn (:conn components) + out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" out test-network-id)] + (handle-join ["#test"] alice + (assoc components :clients (atom {"alice" {:w out}}) + :channels (atom {}))) + ;; GET with no marker + (let [replies (handle-markread ["#test"] alice components)] + (is (string/includes? (first replies) "MARKREAD #test *"))) + ;; SET + (let [ts "timestamp=2026-04-22T10:00:00.000Z" + replies (handle-markread ["#test" ts] alice components)] + (is (string/includes? (first replies) (str "MARKREAD #test " ts)))) + ;; GET returns stored value + (let [replies (handle-markread ["#test"] alice components)] + (is (string/includes? (first replies) "2026-04-22T10:00:00.000Z")))))) + +(deftest test_isupport-and-oper + (testing "RPL_ISUPPORT advertises required tokens" + (let [c (client)] + (replies-for! {:command "NICK" :params ["" "joe"]} + c no-conn) + (let [replies (replies-for! + {:command "USER" + :params ["" "joe" "0" "x" ":Joe"]} + c no-conn) + isupport (filter #(re-find #" 005 " %) replies) + joined (string/join " " isupport)] + (is (seq isupport)) + (is (string/includes? joined "PREFIX=(ov)@+")) + (is (string/includes? joined "CHANTYPES=#")) + (is (string/includes? joined "CASEMAPPING=ascii")) + (is (string/includes? joined "NETWORK=papod")) + (is (string/includes? joined "NICKLEN=")) + (is (string/includes? joined "CHANNELLEN=")) + (is (string/includes? joined "TARGMAX="))))) + (testing "OPER with correct credentials sends 381 + MODE +o" + (let [c (registered-client "baz" (java.io.ByteArrayOutputStream.))] + (let [replies (replies-for! + {:command "OPER" + :params ["" "operuser" "operpassword"]} + c no-conn)] + (is (= 2 (count replies))) + (is (string/includes? (first replies) "381")) + (is (re-find #"MODE baz :?\+o" (second replies))) + (is (:oper? @c))))) + (testing "OPER with wrong password returns 464" + (let [c (registered-client "baz" (java.io.ByteArrayOutputStream.))] + (let [replies (replies-for! + {:command "OPER" + :params ["" "operuser" "wrong"]} + c no-conn)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "464")) + (is (not (:oper? @c)))))) + (testing "OPER missing args returns 461" + (let [c (registered-client "baz" (java.io.ByteArrayOutputStream.))] + (let [replies (replies-for! + {:command "OPER" :params ["" "only"]} + c no-conn)] + (is (string/includes? (first replies) "461"))))) + (testing "TIME returns 391" + (let [c (registered-client "baz" (java.io.ByteArrayOutputStream.))] + (let [replies (replies-for! + {:command "TIME" :params []} + c no-conn)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "391")))))) + +(deftest test_channel-modes + (testing "secret channel (+s) uses @ symbol in NAMES" + (let [{:keys [test-network-id] :as components} + (test-components-with-network) + out (java.io.ByteArrayOutputStream.) + c (registered-client "alice" out test-network-id) + comp (assoc components + :clients + (atom {"alice" + {:w out :client-atom c}}))] + (handle-join ["#sec"] c comp) + ;; Mark channel as secret + (swap! (:chan-modes comp) assoc "#sec" "+nts") + (let [replies (replies-for! + {:command "NAMES" :params ["" "#sec"]} + c comp)] + (is (string/includes? (first replies) "353")) + (is (string/includes? (first replies) "@ #sec"))))) + (testing "+i (invite-only) blocks JOIN with 473" + (let [{:keys [test-network-id] :as components} + (test-components-with-network) + a-out (java.io.ByteArrayOutputStream.) + b-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" a-out test-network-id) + bob (registered-client "bob" b-out test-network-id) + comp (assoc components + :clients (atom + {"alice" {:w a-out + :client-atom alice} + "bob" {:w b-out + :client-atom bob}}) + :channels (atom {}))] + (handle-join ["#priv"] alice comp) + (swap! (:chan-modes comp) assoc "#priv" "+nti") + (let [replies (handle-join ["#priv"] bob comp)] + (is (= 1 (count replies))) + (is (string/includes? (first replies) "473"))))) + (testing "INVITE allows +i bypass" + (let [{:keys [test-network-id] :as components} + (test-components-with-network) + a-out (java.io.ByteArrayOutputStream.) + b-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" a-out test-network-id) + bob (registered-client "bob" b-out test-network-id) + comp (assoc components + :clients (atom + {"alice" {:w a-out + :client-atom alice} + "bob" {:w b-out + :client-atom bob}}) + :channels (atom {}))] + (handle-join ["#priv"] alice comp) + (swap! (:chan-modes comp) assoc "#priv" "+nti") + (replies-for! + {:command "INVITE" :params ["" "bob" "#priv"]} + alice comp) + ;; Bob should now be able to join + (let [replies (handle-join ["#priv"] bob comp)] + (is (empty? + (filter #(re-find #" 473 " %) replies)))))) + (testing "+l limit blocks JOIN with 471" + (let [{:keys [test-network-id] :as components} + (test-components-with-network) + a-out (java.io.ByteArrayOutputStream.) + b-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" a-out test-network-id) + bob (registered-client "bob" b-out test-network-id) + comp (assoc components + :clients (atom + {"alice" {:w a-out + :client-atom alice} + "bob" {:w b-out + :client-atom bob}}) + :channels (atom {}))] + (handle-join ["#small"] alice comp) + (swap! (:chan-modes comp) assoc "#small" "+ntl") + (swap! (:chan-limits comp) assoc "#small" 1) + (let [replies (handle-join ["#small"] bob comp)] + (is (string/includes? (first replies) "471"))))) + (testing "WHOWAS returns history after QUIT" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (test-components) + comp (assoc components + :clients (atom {"bob" {:client-atom + (atom + {:nick "bob" + :registered? true + :user + {:username "bobu" + :realname "Bob"}}) + :w + (java.io.ByteArrayOutputStream.)}}))] + ;; Bob quits + (let [bob (:client-atom (get @(:clients comp) "bob"))] + (replies-for! {:command "QUIT" :params [":bye"]} + bob comp)) + ;; Alice WHOWAS bob — gets history + (let [replies (replies-for! + {:command "WHOWAS" :params ["" "bob"]} + c comp)] + (is (>= (count replies) 2)) + (is (some #(re-find #" 314 " %) replies)) + (is (some #(re-find #" 369 " %) replies)) + (is (not (some #(re-find #" 406 " %) replies)))))) + (testing "WHOWAS for unknown nick returns 406" + (let [c (registered-client "alice" + (java.io.ByteArrayOutputStream.)) + components (test-components)] + (let [replies (replies-for! + {:command "WHOWAS" + :params ["" "neverexisted"]} + c components)] + (is (some #(re-find #" 406 " %) replies)) + (is (some #(re-find #" 369 " %) replies))))) + (testing "+k key blocks JOIN with 475" + (let [{:keys [test-network-id] :as components} + (test-components-with-network) + a-out (java.io.ByteArrayOutputStream.) + b-out (java.io.ByteArrayOutputStream.) + alice (registered-client "alice" a-out test-network-id) + bob (registered-client "bob" b-out test-network-id) + comp (assoc components + :clients (atom + {"alice" {:w a-out + :client-atom alice} + "bob" {:w b-out + :client-atom bob}}) + :channels (atom {}))] + (handle-join ["#locked"] alice comp) + (swap! (:chan-modes comp) assoc "#locked" "+ntk") + (swap! (:chan-keys comp) assoc "#locked" "secret") + ;; No key + (let [replies (handle-join ["#locked"] bob comp)] + (is (string/includes? (first replies) "475"))) + ;; Wrong key + (let [replies (handle-join ["#locked" "wrong"] + bob comp)] + (is (string/includes? (first replies) "475"))) + ;; Right key + (let [replies (handle-join ["#locked" "secret"] + bob comp)] + (is (empty? + (filter #(re-find #" 475 " %) replies))))))) + +(def read-proxy-v2-authority! @#'papod/read-proxy-v2-authority!) + +(defn- bis-of + ^java.io.BufferedInputStream [^bytes b] + (java.io.BufferedInputStream. + (java.io.ByteArrayInputStream. b) + 4096)) + +(defn- proxy-v2-bytes + "Construct a PROXY v2 LOCAL/AF_UNSPEC header carrying a single + AUTHORITY TLV. Mirrors what untls writes to the upstream socket." + ^bytes [^String authority] + (let [auth (.getBytes authority "UTF-8") + sig (byte-array + [(unchecked-byte 0x0D) (unchecked-byte 0x0A) + (unchecked-byte 0x0D) (unchecked-byte 0x0A) + (unchecked-byte 0x00) (unchecked-byte 0x0D) + (unchecked-byte 0x0A) (unchecked-byte 0x51) + (unchecked-byte 0x55) (unchecked-byte 0x49) + (unchecked-byte 0x54) (unchecked-byte 0x0A)]) + tlv-len (+ 3 (alength auth)) + out (java.io.ByteArrayOutputStream.)] + (.write out sig) + (.write out 0x21) + (.write out 0x00) + (.write out (bit-and (bit-shift-right tlv-len 8) 0xFF)) + (.write out (bit-and tlv-len 0xFF)) + (.write out 0x02) + (.write out (bit-and (bit-shift-right (alength auth) 8) 0xFF)) + (.write out (bit-and (alength auth) 0xFF)) + (.write out auth) + (.toByteArray out))) + +(deftest test_proxy-v2-parser + (testing "valid PROXY v2 with AUTHORITY returns the SNI" + (let [hdr (proxy-v2-bytes "papo.example.com") + tail (.getBytes "NICK alice\r\n" "UTF-8") + buf (byte-array (+ (alength hdr) (alength tail)))] + (System/arraycopy hdr 0 buf 0 (alength hdr)) + (System/arraycopy tail 0 buf (alength hdr) (alength tail)) + (let [bis (bis-of buf) + authority (read-proxy-v2-authority! bis)] + (is (= authority "papo.example.com")) + ;; The trailing IRC bytes should remain readable. + (let [out (byte-array (alength tail)) + _ (.read bis out)] + (is (= (String. out "UTF-8") "NICK alice\r\n")))))) + + (testing "no PROXY signature returns nil and preserves bytes" + (let [bis (bis-of (.getBytes "NICK alice\r\n" "UTF-8"))] + (is (nil? (read-proxy-v2-authority! bis))) + (let [out (byte-array 12) + _ (.read bis out)] + (is (= (String. out "UTF-8") "NICK alice\r\n"))))) + + (testing "PROXY v2 without AUTHORITY returns empty string" + (let [sig (byte-array + [(unchecked-byte 0x0D) (unchecked-byte 0x0A) + (unchecked-byte 0x0D) (unchecked-byte 0x0A) + (unchecked-byte 0x00) (unchecked-byte 0x0D) + (unchecked-byte 0x0A) (unchecked-byte 0x51) + (unchecked-byte 0x55) (unchecked-byte 0x49) + (unchecked-byte 0x54) (unchecked-byte 0x0A)]) + out (java.io.ByteArrayOutputStream.)] + (.write out sig) + (.write out 0x21) + (.write out 0x00) + (.write out 0x00) + (.write out 0x00) + (let [bis (bis-of (.toByteArray out))] + (is (= (read-proxy-v2-authority! bis) "")))))) + +(defn -main + [& _args] + (binding [*out* *err*] + (let [{:keys [fail error] :as res} (t/run-tests 'unit) + status (if (zero? (+ fail error)) + 0 + 1)] + (prn res) + (System/exit status)))) |
