diff --git a/flake.nix b/flake.nix --- a/flake.nix +++ b/flake.nix @@ -95,10 +95,14 @@ knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; knot = self.callPackage ./nix/pkgs/knot.nix {}; dolly = self.callPackage ./nix/pkgs/dolly.nix {}; + did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {}; + bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {}; + bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {}; + tap = self.callPackage ./nix/pkgs/tap.nix {}; }); in { overlays.default = final: prev: { - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly did-method-plc bluesky-jetstream bluesky-relay tap; }; packages = forAllSystems (system: let @@ -119,6 +123,10 @@ sqlite-lib docs dolly + did-method-plc + bluesky-jetstream + bluesky-relay + tap ; pkgsStatic-appview = staticPackages.appview; @@ -248,7 +256,7 @@ rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) cd "$rootDir" - mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} + mkdir -p nix/vm-data/{caddy,knot,repos,spindle,spindle-logs} export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" exec ${pkgs.lib.getExe @@ -323,6 +331,30 @@ imports = [./nix/modules/spindle.nix]; services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; + }; + nixosModules.did-method-plc = { + lib, + pkgs, + ... + }: { + imports = [./nix/modules/did-method-plc.nix]; + services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc; + }; + nixosModules.bluesky-relay = { + lib, + pkgs, + ... + }: { + imports = [./nix/modules/bluesky-relay.nix]; + services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay; + }; + nixosModules.bluesky-jetstream = { + lib, + pkgs, + ... + }: { + imports = [./nix/modules/bluesky-jetstream.nix]; + services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream; }; }; } diff --git a/input.css b/input.css --- a/input.css +++ b/input.css @@ -99,6 +99,9 @@ border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500; } + textarea { + @apply font-mono; + } details summary::-webkit-details-marker { display: none; } diff --git a/appview/ingester.go b/appview/ingester.go --- a/appview/ingester.go +++ b/appview/ingester.go @@ -79,6 +79,8 @@ err = i.ingestString(e) case tangled.RepoIssueNSID: err = i.ingestIssue(ctx, e) + case tangled.CommentNSID: + err = i.ingestComment(e) case tangled.RepoIssueCommentNSID: err = i.ingestIssueComment(e) case tangled.LabelDefinitionNSID: @@ -889,7 +891,7 @@ } switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jmodels.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.RepoIssueComment{} err = json.Unmarshal(raw, &record) @@ -897,12 +899,20 @@ return fmt.Errorf("invalid record: %w", err) } - comment, err := models.IssueCommentFromRecord(did, rkey, record) + // convert 'sh.tangled.repo.issue.comment' to 'sh.tangled.comment' + comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), tangled.Comment{ + Body: record.Body, + CreatedAt: record.CreatedAt, + Mentions: record.Mentions, + References: record.References, + ReplyTo: record.ReplyTo, + Subject: record.Issue, + }) if err != nil { return fmt.Errorf("failed to parse comment from record: %w", err) } - if err := i.Validator.ValidateIssueComment(comment); err != nil { + if err := comment.Validate(); err != nil { return fmt.Errorf("failed to validate comment: %w", err) } @@ -912,20 +922,90 @@ } defer tx.Rollback() - _, err = db.AddIssueComment(tx, *comment) + err = db.PutComment(tx, comment) if err != nil { - return fmt.Errorf("failed to create issue comment: %w", err) + return fmt.Errorf("failed to create comment: %w", err) } return tx.Commit() case jmodels.CommitOperationDelete: - if err := db.DeleteIssueComments( + if err := db.DeleteComments( ddb, orm.FilterEq("did", did), + orm.FilterEq("collection", e.Commit.Collection), orm.FilterEq("rkey", rkey), ); err != nil { return fmt.Errorf("failed to delete issue comment record: %w", err) + } + + return nil + } + + return nil +} + +func (i *Ingester) ingestComment(e *jmodels.Event) error { + did := e.Did + rkey := e.Commit.RKey + + var err error + + l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) + l.Info("ingesting record") + + ddb, ok := i.Db.Execer.(*db.DB) + if !ok { + return fmt.Errorf("failed to index issue comment record, invalid db cast") + } + + switch e.Commit.Operation { + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + raw := json.RawMessage(e.Commit.Record) + record := tangled.Comment{} + err = json.Unmarshal(raw, &record) + if err != nil { + return fmt.Errorf("invalid record: %w", err) + } + + comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), record) + if err != nil { + return fmt.Errorf("failed to parse comment from record: %w", err) + } + + // TODO: ingest pull comments + // we aren't ingesting pull comments yet because pull itself isn't fully atprotated. + // so we cannot know which round this comment is pointing to + if comment.Subject.Collection().String() == tangled.RepoPullNSID { + l.Info("skip ingesting pull comments") + return nil + } + + if err := comment.Validate(); err != nil { + return fmt.Errorf("failed to validate comment: %w", err) + } + + tx, err := ddb.Begin() + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer tx.Rollback() + + err = db.PutComment(tx, comment) + if err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + + return tx.Commit() + + case jmodels.CommitOperationDelete: + if err := db.DeleteComments( + ddb, + orm.FilterEq("did", did), + orm.FilterEq("collection", e.Commit.Collection), + orm.FilterEq("rkey", rkey), + ); err != nil { + return fmt.Errorf("failed to delete comment record: %w", err) } return nil diff --git a/contrib/example.env b/contrib/example.env new file mode 100644 --- /dev/null +++ b/contrib/example.env @@ -0,0 +1,31 @@ +# NOTE: put actual DIDs here +alice_did=did:plc:alice-did +tangled_did=did:plc:tangled-did + +#core +export TANGLED_DEV=true +export TANGLED_APPVIEW_HOST=127.0.0.1:3000 +# plc +export TANGLED_PLC_URL=https://plc.tngl.boltless.dev +# jetstream +export TANGLED_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe +# label +export TANGLED_LABEL_GFI=at://${tangled_did}/sh.tangled.label.definition/good-first-issue +export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_GFI +export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/assignee +export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/documentation +export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/duplicate +export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/wontfix + +# vm settings +export TANGLED_VM_PLC_URL=https://plc.tngl.boltless.dev +export TANGLED_VM_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe +export TANGLED_VM_KNOT_HOST=knot.tngl.boltless.dev +export TANGLED_VM_KNOT_OWNER=$alice_did +export TANGLED_VM_SPINDLE_HOST=spindle.tngl.boltless.dev +export TANGLED_VM_SPINDLE_OWNER=$alice_did + +if [ -n "${TANGLED_RESEND_API_KEY:-}" ] && [ -n "${TANGLED_RESEND_SENT_FROM:-}" ]; then + export TANGLED_VM_PDS_EMAIL_SMTP_URL=smtps://resend:$TANGLED_RESEND_API_KEY@smtp.resend.com:465/ + export TANGLED_VM_PDS_EMAIL_FROM_ADDRESS=$TANGLED_RESEND_SENT_FROM +fi diff --git a/contrib/pds.env b/contrib/pds.env new file mode 100644 --- /dev/null +++ b/contrib/pds.env @@ -0,0 +1,12 @@ +LOG_ENABLED=true + +PDS_JWT_SECRET=8cae8bffcc73d9932819650791e4e89a +PDS_ADMIN_PASSWORD=d6a902588cd93bee1af83f924f60cfd3 +PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7 + +PDS_DATA_DIRECTORY=/pds +PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks + +PDS_DID_PLC_URL=http://localhost:8080 +PDS_HOSTNAME=pds.tngl.boltless.dev +PDS_PORT=3000 diff --git a/contrib/readme.md b/contrib/readme.md new file mode 100644 --- /dev/null +++ b/contrib/readme.md @@ -0,0 +1,25 @@ +# how to setup local appview dev environment + +Appview requires several microservices from knot and spindle to entire atproto infra. This test environment is implemented under nixos vm. + +1. copy `contrib/example.env` to `.env`, fill it and source it +2. run vm + ```bash + nix run --impure .#vm + ``` +3. trust the generated cert from host machine + ```bash + # for macos + sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain \ + ./nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/root.crt + ``` +4. create test accounts with valid emails (use [`create-test-account.sh`](./scripts/create-test-account.sh)) +5. create default labels (use [`setup-const-records`](./scripts/setup-const-records.sh)) +6. restart vm with correct owner-did + +for git-https, you should change your local git config: +``` +[http "https://knot.tngl.boltless.dev"] + sslCAPath = /Users/boltless/repo/tangled/nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/ +``` diff --git a/jetstream/jetstream.go b/jetstream/jetstream.go --- a/jetstream/jetstream.go +++ b/jetstream/jetstream.go @@ -159,8 +159,9 @@ j.cancelMu.Unlock() if err := j.client.ConnectAndRead(connCtx, cursor); err != nil { - l.Error("error reading jetstream", "error", err) + l.Error("error reading jetstream, retry in 3s", "error", err) cancel() + time.Sleep(3 * time.Second) continue } diff --git a/nix/vm.nix b/nix/vm.nix --- a/nix/vm.nix +++ b/nix/vm.nix @@ -23,6 +23,9 @@ nixpkgs.lib.nixosSystem { inherit system; modules = [ + self.nixosModules.did-method-plc + self.nixosModules.bluesky-jetstream + self.nixosModules.bluesky-relay self.nixosModules.knot self.nixosModules.spindle ({ @@ -39,6 +42,23 @@ diskSize = 10 * 1024; cores = 2; forwardPorts = [ + # caddy + { + from = "host"; + host.port = 80; + guest.port = 80; + } + { + from = "host"; + host.port = 443; + guest.port = 443; + } + { + from = "host"; + proto = "udp"; + host.port = 443; + guest.port = 443; + } # ssh { from = "host"; @@ -63,6 +83,10 @@ # as SQLite is incompatible with them. So instead we # mount the shared directories to a different location # and copy the contents around on service start/stop. + caddyData = { + source = "$TANGLED_VM_DATA_DIR/caddy"; + target = config.services.caddy.dataDir; + }; knotData = { source = "$TANGLED_VM_DATA_DIR/knot"; target = "/mnt/knot-data"; @@ -79,9 +103,19 @@ }; # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall networking.firewall.enable = false; + # resolve `*.tngl.boltless.dev` to host + services.dnsmasq.enable = true; + services.dnsmasq.settings.address = "/tngl.boltless.dev/10.0.2.2"; + security.pki.certificates = [ + (builtins.readFile ../contrib/certs/root.crt) + ]; time.timeZone = "Europe/London"; + services.timesyncd.enable = lib.mkVMOverride true; services.getty.autologinUser = "root"; environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; + virtualisation.docker.extraOptions = '' + --dns 172.17.0.1 + ''; services.tangled.knot = { enable = true; motd = "Welcome to the development knot!\n"; @@ -108,6 +142,94 @@ provider = "sqlite"; }; }; + }; + services.did-method-plc.enable = true; + services.bluesky-pds = { + enable = true; + # overriding package version to support emails + package = pkgs.bluesky-pds.overrideAttrs (old: rec { + version = "0.4.188"; + src = pkgs.fetchFromGitHub { + owner = "bluesky-social"; + repo = "pds"; + tag = "v${version}"; + hash = "sha256-t8KdyEygXdbj/5Rhj8W40e1o8mXprELpjsKddHExmo0="; + }; + pnpmDeps = pkgs.fetchPnpmDeps { + inherit version src; + pname = old.pname; + sourceRoot = old.sourceRoot; + fetcherVersion = 2; + hash = "sha256-lQie7f8JbWKSpoavnMjHegBzH3GB9teXsn+S2SLJHHU="; + }; + }); + settings = { + LOG_ENABLED = "true"; + + PDS_JWT_SECRET = "8cae8bffcc73d9932819650791e4e89a"; + PDS_ADMIN_PASSWORD = "d6a902588cd93bee1af83f924f60cfd3"; + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX = "2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7"; + + PDS_EMAIL_SMTP_URL = envVarOr "TANGLED_VM_PDS_EMAIL_SMTP_URL" null; + PDS_EMAIL_FROM_ADDRESS = envVarOr "TANGLED_VM_PDS_EMAIL_FROM_ADDRESS" null; + + PDS_DID_PLC_URL = "http://localhost:8080"; + PDS_CRAWLERS = "https://relay.tngl.boltless.dev"; + PDS_HOSTNAME = "pds.tngl.boltless.dev"; + PDS_PORT = 3000; + }; + }; + services.bluesky-relay = { + enable = true; + }; + services.bluesky-jetstream = { + enable = true; + livenessTtl = 300; + websocketUrl = "ws://localhost:3000/xrpc/com.atproto.sync.subscribeRepos"; + }; + services.caddy = { + enable = true; + configFile = pkgs.writeText "Caddyfile" '' + { + debug + cert_lifetime 3601d + pki { + ca local { + intermediate_lifetime 3599d + } + } + } + + plc.tngl.boltless.dev { + tls internal + reverse_proxy http://localhost:8080 + } + + *.pds.tngl.boltless.dev, pds.tngl.boltless.dev { + tls internal + reverse_proxy http://localhost:3000 + } + + jetstream.tngl.boltless.dev { + tls internal + reverse_proxy http://localhost:6008 + } + + relay.tngl.boltless.dev { + tls internal + reverse_proxy http://localhost:2470 + } + + knot.tngl.boltless.dev { + tls internal + reverse_proxy http://localhost:6444 + } + + spindle.tngl.boltless.dev { + tls internal + reverse_proxy http://localhost:6555 + } + ''; }; users = { # So we don't have to deal with permission clashing between diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -604,6 +604,422 @@ return nil } +func (t *Comment) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 7 + + if t.Mentions == nil { + fieldCount-- + } + + if t.References == nil { + fieldCount-- + } + + if t.ReplyTo == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Body (string) (string) + if len("body") > 1000000 { + return xerrors.Errorf("Value in field \"body\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { + return err + } + if _, err := cw.WriteString(string("body")); err != nil { + return err + } + + if len(t.Body) > 1000000 { + return xerrors.Errorf("Value in field t.Body was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Body)); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.comment"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sh.tangled.comment")); err != nil { + return err + } + + // t.ReplyTo (string) (string) + if t.ReplyTo != nil { + + if len("replyTo") > 1000000 { + return xerrors.Errorf("Value in field \"replyTo\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { + return err + } + if _, err := cw.WriteString(string("replyTo")); err != nil { + return err + } + + if t.ReplyTo == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.ReplyTo) > 1000000 { + return xerrors.Errorf("Value in field t.ReplyTo was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { + return err + } + } + } + + // t.Subject (string) (string) + if len("subject") > 1000000 { + return xerrors.Errorf("Value in field \"subject\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { + return err + } + if _, err := cw.WriteString(string("subject")); err != nil { + return err + } + + if len(t.Subject) > 1000000 { + return xerrors.Errorf("Value in field t.Subject was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Subject)); err != nil { + return err + } + + // t.Mentions ([]string) (slice) + if t.Mentions != nil { + + if len("mentions") > 1000000 { + return xerrors.Errorf("Value in field \"mentions\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { + return err + } + if _, err := cw.WriteString(string("mentions")); err != nil { + return err + } + + if len(t.Mentions) > 8192 { + return xerrors.Errorf("Slice value in field t.Mentions was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { + return err + } + for _, v := range t.Mentions { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + } + + // t.CreatedAt (string) (string) + if len("createdAt") > 1000000 { + return xerrors.Errorf("Value in field \"createdAt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("createdAt")); err != nil { + return err + } + + if len(t.CreatedAt) > 1000000 { + return xerrors.Errorf("Value in field t.CreatedAt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { + return err + } + + // t.References ([]string) (slice) + if t.References != nil { + + if len("references") > 1000000 { + return xerrors.Errorf("Value in field \"references\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { + return err + } + if _, err := cw.WriteString(string("references")); err != nil { + return err + } + + if len(t.References) > 8192 { + return xerrors.Errorf("Slice value in field t.References was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { + return err + } + for _, v := range t.References { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + } + return nil +} + +func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { + *t = Comment{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("Comment: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 10) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Body (string) (string) + case "body": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Body = string(sval) + } + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.ReplyTo (string) (string) + case "replyTo": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.ReplyTo = (*string)(&sval) + } + } + // t.Subject (string) (string) + case "subject": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Subject = string(sval) + } + // t.Mentions ([]string) (slice) + case "mentions": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Mentions: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Mentions = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Mentions[i] = string(sval) + } + + } + } + // t.CreatedAt (string) (string) + case "createdAt": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.CreatedAt = string(sval) + } + // t.References ([]string) (slice) + case "references": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.References: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.References = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.References[i] = string(sval) + } + + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} func (t *FeedReaction) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) diff --git a/api/tangled/tangledcomment.go b/api/tangled/tangledcomment.go new file mode 100644 --- /dev/null +++ b/api/tangled/tangledcomment.go @@ -0,0 +1,27 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.comment + +import ( + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + CommentNSID = "sh.tangled.comment" +) + +func init() { + util.RegisterType("sh.tangled.comment", &Comment{}) +} // +// RECORDTYPE: Comment +type Comment struct { + LexiconTypeID string `json:"$type,const=sh.tangled.comment" cborgen:"$type,const=sh.tangled.comment"` + Body string `json:"body" cborgen:"body"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` + References []string `json:"references,omitempty" cborgen:"references,omitempty"` + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` + Subject string `json:"subject" cborgen:"subject"` +} diff --git a/appview/db/comments.go b/appview/db/comments.go new file mode 100644 --- /dev/null +++ b/appview/db/comments.go @@ -0,0 +1,202 @@ +package db + +import ( + "database/sql" + "fmt" + "maps" + "slices" + "sort" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/models" + "tangled.org/core/orm" +) + +func PutComment(tx *sql.Tx, c *models.Comment) error { + if c.Collection == "" { + c.Collection = tangled.CommentNSID + } + result, err := tx.Exec( + `insert into comments ( + did, + collection, + rkey, + subject_at, + reply_to, + body, + pull_submission_id, + created + ) + values (?, ?, ?, ?, ?, ?, ?, ?) + on conflict(did, collection, rkey) do update set + subject_at = excluded.subject_at, + reply_to = excluded.reply_to, + body = excluded.body, + edited = case + when + comments.subject_at != excluded.subject_at + or comments.body != excluded.body + or comments.reply_to != excluded.reply_to + then ? + else comments.edited + end`, + c.Did, + c.Collection, + c.Rkey, + c.Subject, + c.ReplyTo, + c.Body, + c.PullSubmissionId, + c.Created.Format(time.RFC3339), + time.Now().Format(time.RFC3339), + ) + if err != nil { + return err + } + + c.Id, err = result.LastInsertId() + if err != nil { + return err + } + + if err := putReferences(tx, c.AtUri(), c.References); err != nil { + return fmt.Errorf("put reference_links: %w", err) + } + + return nil +} + +func DeleteComments(e Execer, filters ...orm.Filter) error { + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf(`update comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) + + _, err := e.Exec(query, args...) + return err +} + +func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { + commentMap := make(map[string]*models.Comment) + + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf(` + select + id, + did, + collection, + rkey, + subject_at, + reply_to, + body, + pull_submission_id, + created, + edited, + deleted + from + comments + %s + `, whereClause) + + rows, err := e.Query(query, args...) + if err != nil { + return nil, err + } + + for rows.Next() { + var comment models.Comment + var created string + var edited, deleted, replyTo sql.Null[string] + err := rows.Scan( + &comment.Id, + &comment.Did, + &comment.Collection, + &comment.Rkey, + &comment.Subject, + &replyTo, + &comment.Body, + &comment.PullSubmissionId, + &created, + &edited, + &deleted, + ) + if err != nil { + return nil, err + } + + if t, err := time.Parse(time.RFC3339, created); err == nil { + comment.Created = t + } + + if edited.Valid { + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { + comment.Edited = &t + } + } + + if deleted.Valid { + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { + comment.Deleted = &t + } + } + + if replyTo.Valid { + rt := syntax.ATURI(replyTo.V) + comment.ReplyTo = &rt + } + + atUri := comment.AtUri().String() + commentMap[atUri] = &comment + } + + if err := rows.Err(); err != nil { + return nil, err + } + defer rows.Close() + + // collect references from each comments + commentAts := slices.Collect(maps.Keys(commentMap)) + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) + if err != nil { + return nil, fmt.Errorf("failed to query reference_links: %w", err) + } + for commentAt, references := range allReferencs { + if comment, ok := commentMap[commentAt.String()]; ok { + comment.References = references + } + } + + var comments []models.Comment + for _, c := range commentMap { + comments = append(comments, *c) + } + + sort.Slice(comments, func(i, j int) bool { + return comments[i].Created.Before(comments[j].Created) + }) + + return comments, nil +} diff --git a/appview/db/db.go b/appview/db/db.go --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1181,6 +1181,87 @@ return err }) + orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error { + _, err := tx.Exec(` + drop table if exists comments; + + create table comments ( + -- identifiers + id integer primary key autoincrement, + did text not null, + collection text not null default 'sh.tangled.comment', + rkey text not null, + at_uri text generated always as ('at://' || did || '/' || collection || '/' || rkey) stored, + + -- at identifiers + subject_at text not null, + reply_to text, -- at_uri of parent comment + + pull_submission_id integer, -- dirty fix until we atprotate the pull-rounds + + -- content + body text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + edited text, + deleted text, + + -- constraints + unique(did, collection, rkey) + ); + + insert into comments ( + did, + collection, + rkey, + subject_at, + reply_to, + body, + created, + edited, + deleted + ) + select + did, + 'sh.tangled.repo.issue.comment', + rkey, + issue_at, + reply_to, + body, + created, + edited, + deleted + from issue_comments + where rkey is not null; + + insert into comments ( + did, + collection, + rkey, + subject_at, + pull_submission_id, + body, + created + ) + select + c.owner_did, + 'sh.tangled.repo.pull.comment', + substr( + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey + instr( + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey + '/' + ) + 1 + ), -- rkey + p.at_uri, + c.submission_id, + c.body, + c.created + from pull_comments c + join pulls p on c.repo_at = p.repo_at and c.pull_id = p.pull_id; + `) + return err + }) + return &DB{ db, logger, diff --git a/appview/db/issues.go b/appview/db/issues.go --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -100,7 +100,7 @@ } func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { - issueMap := make(map[string]*models.Issue) // at-uri -> issue + issueMap := make(map[syntax.ATURI]*models.Issue) // at-uri -> issue var conditions []string var args []any @@ -196,8 +196,7 @@ } } - atUri := issue.AtUri().String() - issueMap[atUri] = &issue + issueMap[issue.AtUri()] = &issue } // collect reverse repos @@ -229,12 +228,12 @@ // collect comments issueAts := slices.Collect(maps.Keys(issueMap)) - comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) + comments, err := GetComments(e, orm.FilterIn("subject_at", issueAts)) if err != nil { return nil, fmt.Errorf("failed to query comments: %w", err) } for i := range comments { - issueAt := comments[i].IssueAt + issueAt := comments[i].Subject if issue, ok := issueMap[issueAt]; ok { issue.Comments = append(issue.Comments, comments[i]) } @@ -246,7 +245,7 @@ return nil, fmt.Errorf("failed to query labels: %w", err) } for issueAt, labels := range allLabels { - if issue, ok := issueMap[issueAt.String()]; ok { + if issue, ok := issueMap[issueAt]; ok { issue.Labels = labels } } @@ -257,7 +256,7 @@ return nil, fmt.Errorf("failed to query reference_links: %w", err) } for issueAt, references := range allReferencs { - if issue, ok := issueMap[issueAt.String()]; ok { + if issue, ok := issueMap[issueAt]; ok { issue.References = references } } @@ -293,185 +292,6 @@ func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { return GetIssuesPaginated(e, pagination.Page{}, filters...) -} - -func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { - result, err := tx.Exec( - `insert into issue_comments ( - did, - rkey, - issue_at, - body, - reply_to, - created, - edited - ) - values (?, ?, ?, ?, ?, ?, null) - on conflict(did, rkey) do update set - issue_at = excluded.issue_at, - body = excluded.body, - edited = case - when - issue_comments.issue_at != excluded.issue_at - or issue_comments.body != excluded.body - or issue_comments.reply_to != excluded.reply_to - then ? - else issue_comments.edited - end`, - c.Did, - c.Rkey, - c.IssueAt, - c.Body, - c.ReplyTo, - c.Created.Format(time.RFC3339), - time.Now().Format(time.RFC3339), - ) - if err != nil { - return 0, err - } - - id, err := result.LastInsertId() - if err != nil { - return 0, err - } - - if err := putReferences(tx, c.AtUri(), c.References); err != nil { - return 0, fmt.Errorf("put reference_links: %w", err) - } - - return id, nil -} - -func DeleteIssueComments(e Execer, filters ...orm.Filter) error { - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - - query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) - - _, err := e.Exec(query, args...) - return err -} - -func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { - commentMap := make(map[string]*models.IssueComment) - - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - - query := fmt.Sprintf(` - select - id, - did, - rkey, - issue_at, - reply_to, - body, - created, - edited, - deleted - from - issue_comments - %s - `, whereClause) - - rows, err := e.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - for rows.Next() { - var comment models.IssueComment - var created string - var rkey, edited, deleted, replyTo sql.Null[string] - err := rows.Scan( - &comment.Id, - &comment.Did, - &rkey, - &comment.IssueAt, - &replyTo, - &comment.Body, - &created, - &edited, - &deleted, - ) - if err != nil { - return nil, err - } - - // this is a remnant from old times, newer comments always have rkey - if rkey.Valid { - comment.Rkey = rkey.V - } - - if t, err := time.Parse(time.RFC3339, created); err == nil { - comment.Created = t - } - - if edited.Valid { - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { - comment.Edited = &t - } - } - - if deleted.Valid { - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { - comment.Deleted = &t - } - } - - if replyTo.Valid { - comment.ReplyTo = &replyTo.V - } - - atUri := comment.AtUri().String() - commentMap[atUri] = &comment - } - - if err = rows.Err(); err != nil { - return nil, err - } - - // collect references for each comments - commentAts := slices.Collect(maps.Keys(commentMap)) - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) - if err != nil { - return nil, fmt.Errorf("failed to query reference_links: %w", err) - } - for commentAt, references := range allReferencs { - if comment, ok := commentMap[commentAt.String()]; ok { - comment.References = references - } - } - - var comments []models.IssueComment - for _, c := range commentMap { - comments = append(comments, *c) - } - - sort.Slice(comments, func(i, j int) bool { - return comments[i].Created.After(comments[j].Created) - }) - - return comments, nil } func DeleteIssues(tx *sql.Tx, did, rkey string) error { diff --git a/appview/db/pulls.go b/appview/db/pulls.go --- a/appview/db/pulls.go +++ b/appview/db/pulls.go @@ -391,15 +391,17 @@ return nil, err } - // Get comments for all submissions using GetPullComments + // Get comments for all submissions using GetComments submissionIds := slices.Collect(maps.Keys(submissionMap)) - comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) + comments, err := GetComments(e, orm.FilterIn("pull_submission_id", submissionIds)) if err != nil { return nil, fmt.Errorf("failed to get pull comments: %w", err) } for _, comment := range comments { - if submission, ok := submissionMap[comment.SubmissionId]; ok { - submission.Comments = append(submission.Comments, comment) + if comment.PullSubmissionId != nil { + if submission, ok := submissionMap[*comment.PullSubmissionId]; ok { + submission.Comments = append(submission.Comments, comment) + } } } @@ -417,96 +419,6 @@ } return m, nil -} - -func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - - query := fmt.Sprintf(` - select - id, - pull_id, - submission_id, - repo_at, - owner_did, - comment_at, - body, - created - from - pull_comments - %s - order by - created asc - `, whereClause) - - rows, err := e.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - commentMap := make(map[string]*models.PullComment) - for rows.Next() { - var comment models.PullComment - var createdAt string - err := rows.Scan( - &comment.ID, - &comment.PullId, - &comment.SubmissionId, - &comment.RepoAt, - &comment.OwnerDid, - &comment.CommentAt, - &comment.Body, - &createdAt, - ) - if err != nil { - return nil, err - } - - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { - comment.Created = t - } - - atUri := comment.AtUri().String() - commentMap[atUri] = &comment - } - - if err := rows.Err(); err != nil { - return nil, err - } - - // collect references for each comments - commentAts := slices.Collect(maps.Keys(commentMap)) - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) - if err != nil { - return nil, fmt.Errorf("failed to query reference_links: %w", err) - } - for commentAt, references := range allReferencs { - if comment, ok := commentMap[commentAt.String()]; ok { - comment.References = references - } - } - - var comments []models.PullComment - for _, c := range commentMap { - comments = append(comments, *c) - } - - sort.Slice(comments, func(i, j int) bool { - return comments[i].Created.Before(comments[j].Created) - }) - - return comments, nil } // timeframe here is directly passed into the sql query filter, and any @@ -583,33 +495,6 @@ } return pulls, nil -} - -func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { - query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` - res, err := tx.Exec( - query, - comment.OwnerDid, - comment.RepoAt, - comment.SubmissionId, - comment.CommentAt, - comment.PullId, - comment.Body, - ) - if err != nil { - return 0, err - } - - i, err := res.LastInsertId() - if err != nil { - return 0, err - } - - if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { - return 0, fmt.Errorf("put reference_links: %w", err) - } - - return i, nil } func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error { diff --git a/appview/db/reference.go b/appview/db/reference.go --- a/appview/db/reference.go +++ b/appview/db/reference.go @@ -11,7 +11,7 @@ "tangled.org/core/orm" ) -// ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. +// ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs. // It will ignore missing refLinks. func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { var ( @@ -53,8 +53,7 @@ values %s ) select - i.did, i.rkey, - c.did, c.rkey + i.at_uri, c.at_uri from input inp join repos r on r.did = inp.owner_did @@ -62,9 +61,9 @@ join issues i on i.repo_at = r.at_uri and i.issue_id = inp.issue_id - left join issue_comments c + left join comments c on inp.comment_id is not null - and c.issue_at = i.at_uri + and c.subject_at = i.at_uri and c.id = inp.comment_id `, strings.Join(vals, ","), @@ -79,26 +78,16 @@ for rows.Next() { // Scan rows - var issueOwner, issueRkey string - var commentOwner, commentRkey sql.NullString + var issueUri string + var commentUri sql.NullString var uri syntax.ATURI - if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { + if err := rows.Scan(&issueUri, &commentUri); err != nil { return nil, err } - if commentOwner.Valid && commentRkey.Valid { - uri = syntax.ATURI(fmt.Sprintf( - "at://%s/%s/%s", - commentOwner.String, - tangled.RepoIssueCommentNSID, - commentRkey.String, - )) + if commentUri.Valid { + uri = syntax.ATURI(commentUri.String) } else { - uri = syntax.ATURI(fmt.Sprintf( - "at://%s/%s/%s", - issueOwner, - tangled.RepoIssueNSID, - issueRkey, - )) + uri = syntax.ATURI(issueUri) } uris = append(uris, uri) } @@ -124,8 +113,7 @@ values %s ) select - p.owner_did, p.rkey, - c.comment_at + p.owner_did, p.rkey, c.at_uri from input inp join repos r on r.did = inp.owner_did @@ -133,9 +121,9 @@ join pulls p on p.repo_at = r.at_uri and p.pull_id = inp.pull_id - left join pull_comments c + left join comments c on inp.comment_id is not null - and c.repo_at = r.at_uri and c.pull_id = p.pull_id + and c.subject_at = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) and c.id = inp.comment_id `, strings.Join(vals, ","), @@ -283,7 +271,7 @@ return nil, fmt.Errorf("get issue backlinks: %w", err) } backlinks = append(backlinks, ls...) - ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) if err != nil { return nil, fmt.Errorf("get issue_comment backlinks: %w", err) } @@ -293,7 +281,7 @@ return nil, fmt.Errorf("get pull backlinks: %w", err) } backlinks = append(backlinks, ls...) - ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) if err != nil { return nil, fmt.Errorf("get pull_comment backlinks: %w", err) } @@ -352,9 +340,9 @@ rows, err := e.Query( fmt.Sprintf( `select r.did, r.name, i.issue_id, c.id, i.title, i.open - from issue_comments c + from comments c join issues i - on i.at_uri = c.issue_at + on i.at_uri = c.subject_at join repos r on r.at_uri = i.repo_at where %s`, @@ -428,15 +416,15 @@ if len(aturis) == 0 { return nil, nil } - filter := orm.FilterIn("c.comment_at", aturis) + filter := orm.FilterIn("c.at_uri", aturis) rows, err := e.Query( fmt.Sprintf( `select r.did, r.name, p.pull_id, c.id, p.title, p.state from repos r join pulls p on r.at_uri = p.repo_at - join pull_comments c - on r.at_uri = c.repo_at and p.pull_id = c.pull_id + join comments c + on ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) = c.subject_at where %s`, filter.Condition(), ), diff --git a/appview/issues/issues.go b/appview/issues/issues.go --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -402,34 +402,39 @@ body := r.FormValue("body") if body == "" { - rp.pages.Notice(w, "issue", "Body is required") + rp.pages.Notice(w, "issue-comment", "Body is required") return } - replyToUri := r.FormValue("reply-to") - var replyTo *string - if replyToUri != "" { - replyTo = &replyToUri + var replyTo *syntax.ATURI + replyToRaw := r.FormValue("reply-to") + if replyToRaw != "" { + aturi, err := syntax.ParseATURI(replyToRaw) + if err != nil { + rp.pages.Notice(w, "issue-comment", "reply-to should be valid AT-URI") + return + } + replyTo = &aturi } mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) - comment := models.IssueComment{ - Did: user.Did, + comment := models.Comment{ + Did: syntax.DID(user.Did), + Collection: tangled.CommentNSID, Rkey: tid.TID(), - IssueAt: issue.AtUri().String(), + Subject: issue.AtUri(), ReplyTo: replyTo, Body: body, Created: time.Now(), Mentions: mentions, References: references, } - if err = rp.validator.ValidateIssueComment(&comment); err != nil { + if err = comment.Validate(); err != nil { l.Error("failed to validate comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } - record := comment.AsRecord() client, err := rp.oauth.AuthorizedClient(r) if err != nil { @@ -440,11 +445,11 @@ // create a record first resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: comment.Did, + Collection: comment.Collection.String(), + Repo: comment.Did.String(), Rkey: comment.Rkey, Record: &lexutil.LexiconTypeDecoder{ - Val: &record, + Val: comment.AsRecord(), }, }) if err != nil { @@ -467,7 +472,7 @@ } defer tx.Rollback() - commentId, err := db.AddIssueComment(tx, comment) + err = db.PutComment(tx, &comment) if err != nil { l.Error("failed to create comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") @@ -483,13 +488,10 @@ // reset atUri to make rollback a no-op atUri = "" - // notify about the new comment - comment.Id = commentId - - rp.notifier.NewIssueComment(r.Context(), &comment, mentions) + rp.notifier.NewComment(r.Context(), &comment) ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) } func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { @@ -504,7 +506,7 @@ } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -540,7 +542,7 @@ } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -556,7 +558,7 @@ } comment := comments[0] - if comment.Did != user.Did { + if comment.Did.String() != user.Did { l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) return @@ -586,8 +588,6 @@ newComment.Edited = &now newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) - record := newComment.AsRecord() - tx, err := rp.db.Begin() if err != nil { l.Error("failed to start transaction", "err", err) @@ -596,7 +596,7 @@ } defer tx.Rollback() - _, err = db.AddIssueComment(tx, newComment) + err = db.PutComment(tx, &newComment) if err != nil { l.Error("failed to perferom update-description query", "err", err) rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") @@ -606,21 +606,23 @@ // rkey is optional, it was introduced later if newComment.Rkey != "" { + // TODO: update correct comment + // update the record on pds - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", newComment.Collection.String(), newComment.Did.String(), newComment.Rkey) if err != nil { l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") + rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update comment, no record found on PDS.") return } _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: user.Did, + Collection: newComment.Collection.String(), + Repo: newComment.Did.String(), Rkey: newComment.Rkey, SwapRecord: ex.Cid, Record: &lexutil.LexiconTypeDecoder{ - Val: &record, + Val: newComment.AsRecord(), }, }) if err != nil { @@ -650,7 +652,7 @@ } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -686,7 +688,7 @@ } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -722,7 +724,7 @@ } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -738,7 +740,7 @@ } comment := comments[0] - if comment.Did != user.Did { + if comment.Did.String() != user.Did { l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) return @@ -751,7 +753,7 @@ // optimistic deletion deleted := time.Now() - err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) + err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) if err != nil { l.Error("failed to delete comment", "err", err) rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") @@ -767,8 +769,8 @@ return } _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: user.Did, + Collection: comment.Collection.String(), + Repo: comment.Did.String(), Rkey: comment.Rkey, }) if err != nil { diff --git a/appview/models/comment.go b/appview/models/comment.go new file mode 100644 --- /dev/null +++ b/appview/models/comment.go @@ -0,0 +1,138 @@ +package models + +import ( + "fmt" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/whyrusleeping/cbor-gen" + "tangled.org/core/api/tangled" +) + +type Comment struct { + Id int64 + Did syntax.DID + Collection syntax.NSID + Rkey string + Subject syntax.ATURI + ReplyTo *syntax.ATURI + Body string + Created time.Time + Edited *time.Time + Deleted *time.Time + Mentions []syntax.DID + References []syntax.ATURI + PullSubmissionId *int +} + +func (c *Comment) AtUri() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, c.Collection, c.Rkey)) +} + +func (c *Comment) AsRecord() typegen.CBORMarshaler { + mentions := make([]string, len(c.Mentions)) + for i, did := range c.Mentions { + mentions[i] = string(did) + } + references := make([]string, len(c.References)) + for i, uri := range c.References { + references[i] = string(uri) + } + var replyTo *string + if c.ReplyTo != nil { + replyToStr := c.ReplyTo.String() + replyTo = &replyToStr + } + switch c.Collection { + case tangled.RepoIssueCommentNSID: + return &tangled.RepoIssueComment{ + Issue: c.Subject.String(), + Body: c.Body, + CreatedAt: c.Created.Format(time.RFC3339), + ReplyTo: replyTo, + Mentions: mentions, + References: references, + } + case tangled.RepoPullCommentNSID: + return &tangled.RepoPullComment{ + Pull: c.Subject.String(), + Body: c.Body, + CreatedAt: c.Created.Format(time.RFC3339), + Mentions: mentions, + References: references, + } + default: // default to CommentNSID + return &tangled.Comment{ + Subject: c.Subject.String(), + Body: c.Body, + CreatedAt: c.Created.Format(time.RFC3339), + ReplyTo: replyTo, + Mentions: mentions, + References: references, + } + } +} + +func (c *Comment) IsTopLevel() bool { + return c.ReplyTo == nil +} + +func (c *Comment) IsReply() bool { + return c.ReplyTo != nil +} + +func (c *Comment) Validate() error { + // TODO: sanitize the body and then trim space + if sb := strings.TrimSpace(c.Body); sb == "" { + return fmt.Errorf("body is empty after HTML sanitization") + } + + // if it's for PR, PullSubmissionId should not be nil + if c.Subject.Collection().String() == tangled.RepoPullNSID { + if c.PullSubmissionId == nil { + return fmt.Errorf("PullSubmissionId should not be nil") + } + } + return nil +} + +func CommentFromRecord(did syntax.DID, rkey syntax.RecordKey, record tangled.Comment) (*Comment, error) { + created, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + created = time.Now() + } + + if _, err = syntax.ParseATURI(record.Subject); err != nil { + return nil, err + } + + i := record + mentions := make([]syntax.DID, len(record.Mentions)) + for i, did := range record.Mentions { + mentions[i] = syntax.DID(did) + } + references := make([]syntax.ATURI, len(record.References)) + for i, uri := range i.References { + references[i] = syntax.ATURI(uri) + } + var replyTo *syntax.ATURI + if record.ReplyTo != nil { + replyToAtUri := syntax.ATURI(*record.ReplyTo) + replyTo = &replyToAtUri + } + + comment := Comment{ + Did: did, + Collection: tangled.CommentNSID, + Rkey: rkey.String(), + Body: record.Body, + Subject: syntax.ATURI(record.Subject), + ReplyTo: replyTo, + Created: created, + Mentions: mentions, + References: references, + } + + return &comment, nil +} diff --git a/appview/models/issue.go b/appview/models/issue.go --- a/appview/models/issue.go +++ b/appview/models/issue.go @@ -26,7 +26,7 @@ // optionally, populate this when querying for reverse mappings // like comment counts, parent repo etc. - Comments []IssueComment + Comments []Comment Labels LabelState Repo *Repo } @@ -62,8 +62,8 @@ } type CommentListItem struct { - Self *IssueComment - Replies []*IssueComment + Self *Comment + Replies []*Comment } func (it *CommentListItem) Participants() []syntax.DID { @@ -88,13 +88,13 @@ func (i *Issue) CommentList() []CommentListItem { // Create a map to quickly find comments by their aturi - toplevel := make(map[string]*CommentListItem) - var replies []*IssueComment + toplevel := make(map[syntax.ATURI]*CommentListItem) + var replies []*Comment // collect top level comments into the map for _, comment := range i.Comments { if comment.IsTopLevel() { - toplevel[comment.AtUri().String()] = &CommentListItem{ + toplevel[comment.AtUri()] = &CommentListItem{ Self: &comment, } } else { @@ -115,7 +115,7 @@ } // sort everything - sortFunc := func(a, b *IssueComment) bool { + sortFunc := func(a, b *Comment) bool { return a.Created.Before(b.Created) } sort.Slice(listing, func(i, j int) bool { @@ -144,7 +144,7 @@ addParticipant(i.Did) for _, c := range i.Comments { - addParticipant(c.Did) + addParticipant(c.Did.String()) } return participants @@ -170,85 +170,4 @@ Body: body, Open: true, // new issues are open by default } -} - -type IssueComment struct { - Id int64 - Did string - Rkey string - IssueAt string - ReplyTo *string - Body string - Created time.Time - Edited *time.Time - Deleted *time.Time - Mentions []syntax.DID - References []syntax.ATURI -} - -func (i *IssueComment) AtUri() syntax.ATURI { - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) -} - -func (i *IssueComment) AsRecord() tangled.RepoIssueComment { - mentions := make([]string, len(i.Mentions)) - for i, did := range i.Mentions { - mentions[i] = string(did) - } - references := make([]string, len(i.References)) - for i, uri := range i.References { - references[i] = string(uri) - } - return tangled.RepoIssueComment{ - Body: i.Body, - Issue: i.IssueAt, - CreatedAt: i.Created.Format(time.RFC3339), - ReplyTo: i.ReplyTo, - Mentions: mentions, - References: references, - } -} - -func (i *IssueComment) IsTopLevel() bool { - return i.ReplyTo == nil -} - -func (i *IssueComment) IsReply() bool { - return i.ReplyTo != nil -} - -func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { - created, err := time.Parse(time.RFC3339, record.CreatedAt) - if err != nil { - created = time.Now() - } - - ownerDid := did - - if _, err = syntax.ParseATURI(record.Issue); err != nil { - return nil, err - } - - i := record - mentions := make([]syntax.DID, len(record.Mentions)) - for i, did := range record.Mentions { - mentions[i] = syntax.DID(did) - } - references := make([]syntax.ATURI, len(record.References)) - for i, uri := range i.References { - references[i] = syntax.ATURI(uri) - } - - comment := IssueComment{ - Did: ownerDid, - Rkey: rkey, - Body: record.Body, - IssueAt: record.Issue, - ReplyTo: record.ReplyTo, - Created: created, - Mentions: mentions, - References: references, - } - - return &comment, nil } diff --git a/appview/models/pull.go b/appview/models/pull.go --- a/appview/models/pull.go +++ b/appview/models/pull.go @@ -138,37 +138,11 @@ RoundNumber int Patch string Combined string - Comments []PullComment + Comments []Comment SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs // meta Created time.Time -} - -type PullComment struct { - // ids - ID int - PullId int - SubmissionId int - - // at ids - RepoAt string - OwnerDid string - CommentAt string - - // content - Body string - - // meta - Mentions []syntax.DID - References []syntax.ATURI - - // meta - Created time.Time -} - -func (p *PullComment) AtUri() syntax.ATURI { - return syntax.ATURI(p.CommentAt) } func (p *Pull) TotalComments() int { @@ -279,7 +253,7 @@ addParticipant(s.PullAt.Authority().String()) for _, c := range s.Comments { - addParticipant(c.OwnerDid) + addParticipant(c.Did.String()) } return participants diff --git a/appview/notify/merged_notifier.go b/appview/notify/merged_notifier.go --- a/appview/notify/merged_notifier.go +++ b/appview/notify/merged_notifier.go @@ -53,12 +53,16 @@ m.fanout("DeleteStar", ctx, star) } -func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { - m.fanout("NewIssue", ctx, issue, mentions) +func (m *mergedNotifier) NewComment(ctx context.Context, comment *models.Comment) { + m.fanout("NewComment", ctx, comment) } -func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { - m.fanout("NewIssueComment", ctx, comment, mentions) +func (m *mergedNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { + m.fanout("DeleteComment", ctx, comment) +} + +func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { + m.fanout("NewIssue", ctx, issue, mentions) } func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { @@ -79,10 +83,6 @@ func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { m.fanout("NewPull", ctx, pull) -} - -func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { - m.fanout("NewPullComment", ctx, comment, mentions) } func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { diff --git a/appview/notify/notifier.go b/appview/notify/notifier.go --- a/appview/notify/notifier.go +++ b/appview/notify/notifier.go @@ -13,8 +13,10 @@ NewStar(ctx context.Context, star *models.Star) DeleteStar(ctx context.Context, star *models.Star) + NewComment(ctx context.Context, comment *models.Comment) + DeleteComment(ctx context.Context, comment *models.Comment) + NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) - NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) DeleteIssue(ctx context.Context, issue *models.Issue) @@ -22,7 +24,6 @@ DeleteFollow(ctx context.Context, follow *models.Follow) NewPull(ctx context.Context, pull *models.Pull) - NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) UpdateProfile(ctx context.Context, profile *models.Profile) @@ -42,18 +43,17 @@ func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} +func (m *BaseNotifier) NewComment(ctx context.Context, comment *models.Comment) {} +func (m *BaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {} + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} -func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { -} func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} -func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} -func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { -} +func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1004,7 +1004,7 @@ LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { @@ -1015,7 +1015,7 @@ LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { @@ -1026,7 +1026,7 @@ LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { @@ -1037,7 +1037,7 @@ LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { diff --git a/appview/pulls/opengraph.go b/appview/pulls/opengraph.go --- a/appview/pulls/opengraph.go +++ b/appview/pulls/opengraph.go @@ -277,7 +277,7 @@ } // Get comment count from database - comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID)) + comments, err := db.GetComments(s.db, orm.FilterEq("subject_at", pull.AtUri())) if err != nil { log.Printf("failed to get pull comments: %v", err) } diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -727,7 +727,23 @@ } defer tx.Rollback() - createdAt := time.Now().Format(time.RFC3339) + comment := models.Comment{ + Did: syntax.DID(user.Did), + Collection: tangled.CommentNSID, + Rkey: tid.TID(), + Subject: pull.AtUri(), + ReplyTo: nil, + Body: body, + Created: time.Now(), + Mentions: mentions, + References: references, + PullSubmissionId: &pull.Submissions[roundNumber].ID, + } + if err = comment.Validate(); err != nil { + log.Println("failed to validate comment", err) + s.pages.Notice(w, "pull-comment", "Failed to create comment.") + return + } client, err := s.oauth.AuthorizedClient(r) if err != nil { @@ -735,16 +751,13 @@ s.pages.Notice(w, "pull-comment", "Failed to create comment.") return } - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoPullCommentNSID, - Repo: user.Did, - Rkey: tid.TID(), + + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: comment.Collection.String(), + Repo: comment.Did.String(), + Rkey: comment.Rkey, Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.RepoPullComment{ - Pull: pull.AtUri().String(), - Body: body, - CreatedAt: createdAt, - }, + Val: comment.AsRecord(), }, }) if err != nil { @@ -753,19 +766,8 @@ return } - comment := &models.PullComment{ - OwnerDid: user.Did, - RepoAt: f.RepoAt().String(), - PullId: pull.PullId, - Body: body, - CommentAt: atResp.Uri, - SubmissionId: pull.Submissions[roundNumber].ID, - Mentions: mentions, - References: references, - } - // Create the pull comment in the database with the commentAt field - commentId, err := db.NewPullComment(tx, comment) + err = db.PutComment(tx, &comment) if err != nil { log.Println("failed to create pull comment", err) s.pages.Notice(w, "pull-comment", "Failed to create comment.") @@ -779,10 +781,10 @@ return } - s.notifier.NewPullComment(r.Context(), comment, mentions) + s.notifier.NewComment(r.Context(), &comment) ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) return } } diff --git a/appview/state/state.go b/appview/state/state.go --- a/appview/state/state.go +++ b/appview/state/state.go @@ -118,6 +118,7 @@ tangled.StringNSID, tangled.RepoIssueNSID, tangled.RepoIssueCommentNSID, + tangled.CommentNSID, tangled.LabelDefinitionNSID, tangled.LabelOpNSID, }, diff --git a/appview/validator/issue.go b/appview/validator/issue.go --- a/appview/validator/issue.go +++ b/appview/validator/issue.go @@ -4,35 +4,8 @@ "fmt" "strings" - "tangled.org/core/appview/db" "tangled.org/core/appview/models" - "tangled.org/core/orm" ) - -func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { - // if comments have parents, only ingest ones that are 1 level deep - if comment.ReplyTo != nil { - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) - if err != nil { - return fmt.Errorf("failed to fetch parent comment: %w", err) - } - if len(parents) != 1 { - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) - } - - // depth check - parent := parents[0] - if parent.ReplyTo != nil { - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") - } - } - - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { - return fmt.Errorf("body is empty after HTML sanitization") - } - - return nil -} func (v *Validator) ValidateIssue(issue *models.Issue) error { if issue.Title == "" { diff --git a/cmd/cborgen/cborgen.go b/cmd/cborgen/cborgen.go --- a/cmd/cborgen/cborgen.go +++ b/cmd/cborgen/cborgen.go @@ -15,6 +15,7 @@ "api/tangled/cbor_gen.go", "tangled", tangled.ActorProfile{}, + tangled.Comment{}, tangled.FeedReaction{}, tangled.FeedStar{}, tangled.GitRefUpdate{}, diff --git a/contrib/certs/root.crt b/contrib/certs/root.crt new file mode 100644 --- /dev/null +++ b/contrib/certs/root.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBozCCAUmgAwIBAgIQRnYoKs3BuihlLFeydgURVzAKBggqhkjOPQQDAjAwMS4w +LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI2IEVDQyBSb290MB4X +DTI2MDEwODEzNTk1MloXDTM1MTExNzEzNTk1MlowMDEuMCwGA1UEAxMlQ2FkZHkg +TG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABCQlYShhxLaX8/ZP7rcBtD5xL4u3wYMe77JS/lRFjjpAUGmJPxUE +ctsNvukG1hU4MeLMSqAEIqFWjs8dQBxLjGSjRTBDMA4GA1UdDwEB/wQEAwIBBjAS +BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBQ7Mt/6izTOOXCSWDS6HrwrqMDB +vzAKBggqhkjOPQQDAgNIADBFAiEA9QAYIuHR5qsGJ1JMZnuAAQpEwaqewhUICsKO +e2fWj4ACICPgj9Kh9++8FH5eVyDI1AD/BLwmMmiaqs1ojZT7QJqb +-----END CERTIFICATE----- diff --git a/contrib/scripts/create-test-account.sh b/contrib/scripts/create-test-account.sh new file mode 100644 --- /dev/null +++ b/contrib/scripts/create-test-account.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -o errexit +set -o nounset +set -o pipefail + +source "$(dirname "$0")/../pds.env" + +# PDS_HOSTNAME= +# PDS_ADMIN_PASSWORD= + +# curl a URL and fail if the request fails. +function curl_cmd_get { + curl --fail --silent --show-error "$@" +} + +# curl a URL and fail if the request fails. +function curl_cmd_post { + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" +} + +# curl a URL but do not fail if the request fails. +function curl_cmd_post_nofail { + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" +} + +USERNAME="${1:-}" + +if [[ "${USERNAME}" == "" ]]; then + read -p "Enter a username: " USERNAME +fi + +if [[ "${USERNAME}" == "" ]]; then + echo "ERROR: missing USERNAME parameter." >/dev/stderr + echo "Usage: $0 ${SUBCOMMAND} " >/dev/stderr + exit 1 +fi + +EMAIL=${USERNAME}@${PDS_HOSTNAME} + +PASSWORD="password" +INVITE_CODE="$(curl_cmd_post \ + --user "admin:${PDS_ADMIN_PASSWORD}" \ + --data '{"useCount": 1}' \ + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" | jq --raw-output '.code' +)" +RESULT="$(curl_cmd_post_nofail \ + --data "{\"email\":\"${EMAIL}\", \"handle\":\"${USERNAME}.${PDS_HOSTNAME}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${INVITE_CODE}\"}" \ + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createAccount" +)" + +DID="$(echo $RESULT | jq --raw-output '.did')" +if [[ "${DID}" != did:* ]]; then + ERR="$(echo ${RESULT} | jq --raw-output '.message')" + echo "ERROR: ${ERR}" >/dev/stderr + echo "Usage: $0 " >/dev/stderr + exit 1 +fi + +echo +echo "Account created successfully!" +echo "-----------------------------" +echo "Handle : ${USERNAME}.${PDS_HOSTNAME}" +echo "DID : ${DID}" +echo "Password : ${PASSWORD}" +echo "-----------------------------" +echo "This is a test account with an insecure password." +echo "Make sure it's only used for development." +echo diff --git a/contrib/scripts/setup-const-records.sh b/contrib/scripts/setup-const-records.sh new file mode 100644 --- /dev/null +++ b/contrib/scripts/setup-const-records.sh @@ -0,0 +1,106 @@ +#!/bin/bash +set -o errexit +set -o nounset +set -o pipefail + +source "$(dirname "$0")/../pds.env" + +# PDS_HOSTNAME= + +# curl a URL and fail if the request fails. +function curl_cmd_get { + curl --fail --silent --show-error "$@" +} + +# curl a URL and fail if the request fails. +function curl_cmd_post { + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" +} + +# curl a URL but do not fail if the request fails. +function curl_cmd_post_nofail { + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" +} + +USERNAME="${1:-}" + +if [[ "${USERNAME}" == "" ]]; then + read -p "Enter a username: " USERNAME +fi + +if [[ "${USERNAME}" == "" ]]; then + echo "ERROR: missing USERNAME parameter." >/dev/stderr + echo "Usage: $0 ${SUBCOMMAND} " >/dev/stderr + exit 1 +fi + +SESS_RESULT="$(curl_cmd_post \ + --data "$(cat < everybody on that thread + // | if the comment is a top level -> just the issue owner + // - remove mentioned users from the recipients list + recipients = sets.New[syntax.DID]() + entityType string + entityId string + repoId *int64 + issueId *int64 + pullId *int64 + ) + + subjectDid, err := comment.Subject.Authority().AsDID() + if err != nil { + log.Printf("NewComment: expected did based at-uri for comment.subject") + return + } + switch comment.Subject.Collection() { + case tangled.RepoIssueNSID: + issues, err := db.GetIssues( + n.db, + orm.FilterEq("did", subjectDid), + orm.FilterEq("rkey", comment.Subject.RecordKey()), + ) + if err != nil { + log.Printf("NewComment: failed to get issues: %v", err) + return + } + if len(issues) == 0 { + log.Printf("NewComment: no issue found for %s", comment.Subject) + return + } + issue := issues[0] + + recipients.Insert(syntax.DID(issue.Repo.Did)) + if comment.IsReply() { + // if this comment is a reply, then notify everybody in that thread + parentAtUri := *comment.ReplyTo + + // find the parent thread, and add all DIDs from here to the recipient list + for _, t := range issue.CommentList() { + if t.Self.AtUri() == parentAtUri { + for _, p := range t.Participants() { + recipients.Insert(p) + } + } + } + } else { + // not a reply, notify just the issue author + recipients.Insert(syntax.DID(issue.Did)) + } + + entityType = "issue" + entityId = issue.AtUri().String() + repoId = &issue.Repo.Id + issueId = &issue.Id + case tangled.RepoPullNSID: + pulls, err := db.GetPulls( + n.db, + orm.FilterEq("owner_did", subjectDid), + orm.FilterEq("rkey", comment.Subject.RecordKey()), + ) + if err != nil { + log.Printf("NewComment: failed to get pulls: %v", err) + return + } + if len(pulls) == 0 { + log.Printf("NewComment: no pull found for %s", comment.Subject) + return + } + pull := pulls[0] + + pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) + if err != nil { + log.Printf("NewComment: failed to get repos: %v", err) + return + } + + recipients.Insert(syntax.DID(pull.Repo.Did)) + for _, p := range pull.Participants() { + recipients.Insert(syntax.DID(p)) + } + + entityType = "pull" + entityId = pull.AtUri().String() + repoId = &pull.Repo.Id + p := int64(pull.ID) + pullId = &p + default: + return // no-op + } + + for _, m := range comment.Mentions { + recipients.Remove(m) + } + + n.notifyEvent( + comment.Did, + recipients, + models.NotificationTypeIssueCommented, + entityType, + entityId, + repoId, + issueId, + pullId, + ) + n.notifyEvent( + comment.Did, + sets.Collect(slices.Values(comment.Mentions)), + models.NotificationTypeUserMentioned, + entityType, + entityId, + repoId, + issueId, + pullId, + ) +} + +func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { + // no-op +} + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) if err != nil { @@ -104,75 +229,6 @@ actorDid, recipients, models.NotificationTypeIssueCreated, - entityType, - entityId, - repoId, - issueId, - pullId, - ) - n.notifyEvent( - actorDid, - sets.Collect(slices.Values(mentions)), - models.NotificationTypeUserMentioned, - entityType, - entityId, - repoId, - issueId, - pullId, - ) -} - -func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { - issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) - if err != nil { - log.Printf("NewIssueComment: failed to get issues: %v", err) - return - } - if len(issues) == 0 { - log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) - return - } - issue := issues[0] - - // built the recipients list: - // - the owner of the repo - // - | if the comment is a reply -> everybody on that thread - // | if the comment is a top level -> just the issue owner - // - remove mentioned users from the recipients list - recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) - - if comment.IsReply() { - // if this comment is a reply, then notify everybody in that thread - parentAtUri := *comment.ReplyTo - - // find the parent thread, and add all DIDs from here to the recipient list - for _, t := range issue.CommentList() { - if t.Self.AtUri().String() == parentAtUri { - for _, p := range t.Participants() { - recipients.Insert(p) - } - } - } - } else { - // not a reply, notify just the issue author - recipients.Insert(syntax.DID(issue.Did)) - } - - for _, m := range mentions { - recipients.Remove(m) - } - - actorDid := syntax.DID(comment.Did) - entityType := "issue" - entityId := issue.AtUri().String() - repoId := &issue.Repo.Id - issueId := &issue.Id - var pullId *int64 - - n.notifyEvent( - actorDid, - recipients, - models.NotificationTypeIssueCommented, entityType, entityId, repoId, @@ -252,65 +308,6 @@ actorDid, recipients, eventType, - entityType, - entityId, - repoId, - issueId, - pullId, - ) -} - -func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { - pull, err := db.GetPull(n.db, - syntax.ATURI(comment.RepoAt), - comment.PullId, - ) - if err != nil { - log.Printf("NewPullComment: failed to get pulls: %v", err) - return - } - - repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) - if err != nil { - log.Printf("NewPullComment: failed to get repos: %v", err) - return - } - - // build up the recipients list: - // - repo owner - // - all pull participants - // - remove those already mentioned - recipients := sets.Singleton(syntax.DID(repo.Did)) - for _, p := range pull.Participants() { - recipients.Insert(syntax.DID(p)) - } - for _, m := range mentions { - recipients.Remove(m) - } - - actorDid := syntax.DID(comment.OwnerDid) - eventType := models.NotificationTypePullCommented - entityType := "pull" - entityId := pull.AtUri().String() - repoId := &repo.Id - var issueId *int64 - p := int64(pull.ID) - pullId := &p - - n.notifyEvent( - actorDid, - recipients, - eventType, - entityType, - entityId, - repoId, - issueId, - pullId, - ) - n.notifyEvent( - actorDid, - sets.Collect(slices.Values(mentions)), - models.NotificationTypeUserMentioned, entityType, entityId, repoId, diff --git a/appview/notify/posthog/notifier.go b/appview/notify/posthog/notifier.go --- a/appview/notify/posthog/notifier.go +++ b/appview/notify/posthog/notifier.go @@ -86,21 +86,6 @@ } } -func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { - err := n.client.Enqueue(posthog.Capture{ - DistinctId: comment.OwnerDid, - Event: "new_pull_comment", - Properties: posthog.Properties{ - "repo_at": comment.RepoAt, - "pull_id": comment.PullId, - "mentions": mentions, - }, - }) - if err != nil { - log.Println("failed to enqueue posthog event:", err) - } -} - func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { err := n.client.Enqueue(posthog.Capture{ DistinctId: pull.OwnerDid, @@ -180,13 +165,13 @@ } } -func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { +func (n *posthogNotifier) NewComment(ctx context.Context, comment *models.Comment) { err := n.client.Enqueue(posthog.Capture{ - DistinctId: comment.Did, - Event: "new_issue_comment", + DistinctId: comment.Did.String(), + Event: "new_comment", Properties: posthog.Properties{ - "issue_at": comment.IssueAt, - "mentions": mentions, + "subject_at": comment.Subject, + "mentions": comment.Mentions, }, }) if err != nil { diff --git a/appview/pages/templates/repo/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html --- a/appview/pages/templates/repo/pulls/pull.html +++ b/appview/pages/templates/repo/pulls/pull.html @@ -561,19 +561,20 @@ {{ end }} {{ define "submissionComment" }} -
+
- {{ template "user/fragments/picLink" (list .OwnerDid "size-8") }} + {{ template "user/fragments/picLink" (list .Did.String "size-8") }}
- {{ $handle := resolve .OwnerDid }} + {{ $handle := resolve .Did.String }} {{ $handle }} - + + {{ template "repo/fragments/time" .Created }} {{ template "repo/fragments/shortTime" .Created }}
diff --git a/appview/pages/templates/strings/fragments/form.html b/appview/pages/templates/strings/fragments/form.html --- a/appview/pages/templates/strings/fragments/form.html +++ b/appview/pages/templates/strings/fragments/form.html @@ -31,7 +31,7 @@ name="content" id="content-textarea" wrap="off" - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" rows="20" spellcheck="false" placeholder="Paste your string here!" diff --git a/appview/pages/templates/repo/issues/fragments/commentList.html b/appview/pages/templates/repo/issues/fragments/commentList.html --- a/appview/pages/templates/repo/issues/fragments/commentList.html +++ b/appview/pages/templates/repo/issues/fragments/commentList.html @@ -41,7 +41,7 @@ {{ define "topLevelComment" }}
- {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }}
{{ template "repo/issues/fragments/issueCommentHeader" . }} @@ -53,7 +53,7 @@ {{ define "replyComment" }}
- {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }}
{{ template "repo/issues/fragments/issueCommentHeader" . }} diff --git a/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html b/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html --- a/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html +++ b/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html @@ -1,11 +1,11 @@ {{ define "repo/issues/fragments/issueCommentHeader" }}
- {{ $handle := resolve .Comment.Did }} + {{ $handle := resolve .Comment.Did.String }} {{ $handle }} {{ template "hats" $ }} {{ template "timestamp" . }} - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} {{ if and $isCommentOwner (not .Comment.Deleted) }} {{ template "editIssueComment" . }} {{ template "deleteIssueComment" . }}