ATProto Browser

ATProto Browser

Experimental browser for the Atmosphere

Record data

{
  "uri": "at://did:plc:qfpnj4og54vl56wngdriaxug/sh.tangled.repo.pull/3lo7lxaochk22",
  "cid": "bafyreifn4cu2agt2qc6jkwmbn6p4wt4ionobymocg6k3lphaksghqxtyuq",
  "value": {
    "$type": "sh.tangled.repo.pull",
    "patch": "From fe01eb842c2a4868df228fc729e6e454d1afcdfa Mon Sep 17 00:00:00 2001\nFrom: Akshay <nerdy@peppe.rs>\nDate: Thu, 1 May 2025 10:51:45 +0100\nSubject: [PATCH 1/2] appview: introduce release artifacts\n\n---\n api/tangled/cbor_gen.go                       | 291 ++++++++++++++++++\n api/tangled/repoartifact.go                   |  31 ++\n appview/db/artifact.go                        | 166 ++++++++++\n appview/db/db.go                              |  23 ++\n appview/db/pulls.go                           |   2 +-\n appview/pages/pages.go                        |  13 +\n .../templates/repo/fragments/artifact.html    |  34 ++\n appview/pages/templates/repo/tags.html        |  97 +++++-\n appview/state/artifact.go                     | 280 +++++++++++++++++\n appview/state/follow.go                       |   2 +-\n appview/state/jetstream.go                    |  70 +++++\n appview/state/repo.go                         |  67 ++--\n appview/state/router.go                       |  19 +-\n appview/state/signer.go                       |  20 +-\n appview/state/star.go                         |   2 +-\n appview/state/state.go                        |   2 +-\n cmd/gen.go                                    |  27 +-\n flake.nix                                     |   1 +\n go.mod                                        |   2 +-\n go.sum                                        |   2 +\n lexicons/artifact.json                        |  52 ++++\n 21 files changed, 1147 insertions(+), 56 deletions(-)\n create mode 100644 api/tangled/repoartifact.go\n create mode 100644 appview/db/artifact.go\n create mode 100644 appview/pages/templates/repo/fragments/artifact.html\n create mode 100644 appview/state/artifact.go\n create mode 100644 appview/state/jetstream.go\n create mode 100644 lexicons/artifact.json\n\ndiff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go\nindex cdd5f35..0b745ba 100644\n--- a/api/tangled/cbor_gen.go\n+++ b/api/tangled/cbor_gen.go\n@@ -8,6 +8,7 @@ import (\n \t\"math\"\n \t\"sort\"\n \n+\tutil \"github.com/bluesky-social/indigo/lex/util\"\n \tcid \"github.com/ipfs/go-cid\"\n \tcbg \"github.com/whyrusleeping/cbor-gen\"\n \txerrors \"golang.org/x/xerrors\"\n@@ -3098,3 +3099,293 @@ func (t *RepoPullComment) UnmarshalCBOR(r io.Reader) (err error) {\n \n \treturn nil\n }\n+func (t *RepoArtifact) MarshalCBOR(w io.Writer) error {\n+\tif t == nil {\n+\t\t_, err := w.Write(cbg.CborNull)\n+\t\treturn err\n+\t}\n+\n+\tcw := cbg.NewCborWriter(w)\n+\tfieldCount := 6\n+\n+\tif t.Tag == nil {\n+\t\tfieldCount--\n+\t}\n+\n+\tif _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {\n+\t\treturn err\n+\t}\n+\n+\t// t.Tag (util.LexBytes) (slice)\n+\tif t.Tag != nil {\n+\n+\t\tif len(\"tag\") > 1000000 {\n+\t\t\treturn xerrors.Errorf(\"Value in field \\\"tag\\\" was too long\")\n+\t\t}\n+\n+\t\tif err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(\"tag\"))); err != nil {\n+\t\t\treturn err\n+\t\t}\n+\t\tif _, err := cw.WriteString(string(\"tag\")); err != nil {\n+\t\t\treturn err\n+\t\t}\n+\n+\t\tif len(t.Tag) > 2097152 {\n+\t\t\treturn xerrors.Errorf(\"Byte array in field t.Tag was too long\")\n+\t\t}\n+\n+\t\tif err := cw.WriteMajorTypeHeader(cbg.MajByteString, uint64(len(t.Tag))); err != nil {\n+\t\t\treturn err\n+\t\t}\n+\n+\t\tif _, err := cw.Write(t.Tag); err != nil {\n+\t\t\treturn err\n+\t\t}\n+\n+\t}\n+\n+\t// t.Name (string) (string)\n+\tif len(\"name\") > 1000000 {\n+\t\treturn xerrors.Errorf(\"Value in field \\\"name\\\" was too long\")\n+\t}\n+\n+\tif err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(\"name\"))); err != nil {\n+\t\treturn err\n+\t}\n+\tif _, err := cw.WriteString(string(\"name\")); err != nil {\n+\t\treturn err\n+\t}\n+\n+\tif len(t.Name) > 1000000 {\n+\t\treturn xerrors.Errorf(\"Value in field t.Name was too long\")\n+\t}\n+\n+\tif err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {\n+\t\treturn err\n+\t}\n+\tif _, err := cw.WriteString(string(t.Name)); err != nil {\n+\t\treturn err\n+\t}\n+\n+\t// t.Repo (string) (string)\n+\tif len(\"repo\") > 1000000 {\n+\t\treturn xerrors.Errorf(\"Value in field \\\"repo\\\" was too long\")\n+\t}\n+\n+\tif err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(\"repo\"))); err != nil {\n+\t\treturn err\n+\t}\n+\tif _, err := cw.WriteString(string(\"repo\")); err != nil {\n+\t\treturn err\n+\t}\n+\n+\tif len(t.Repo) > 1000000 {\n+\t\treturn xerrors.Errorf(\"Value in field t.Repo was too long\")\n+\t}\n+\n+\tif err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil {\n+\t\treturn err\n+\t}\n+\tif _, err := cw.WriteString(string(t.Repo)); err != nil {\n+\t\treturn err\n+\t}\n+\n+\t// t.LexiconTypeID (string) (string)\n+\tif len(\"$type\") > 1000000 {\n+\t\treturn xerrors.Errorf(\"Value in field \\\"$type\\\" was too long\")\n+\t}\n+\n+\tif err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(\"$type\"))); err != nil {\n+\t\treturn err\n+\t}\n+\tif _, err := cw.WriteString(string(\"$type\")); err != nil {\n+\t\treturn err\n+\t}\n+\n+\tif err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(\"sh.tangled.repo.artifact\"))); err != nil {\n+\t\treturn err\n+\t}\n+\tif _, err := cw.WriteString(string(\"sh.tangled.repo.artifact\")); err != nil {\n+\t\treturn err\n+\t}\n+\n+\t// t.Artifact (util.LexBlob) (struct)\n+\tif len(\"artifact\") > 1000000 {\n+\t\treturn xerrors.Errorf(\"Value in field \\\"artifact\\\" was too long\")\n+\t}\n+\n+\tif err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(\"artifact\"))); err != nil {\n+\t\treturn err\n+\t}\n+\tif _, err := cw.WriteString(string(\"artifact\")); err != nil {\n+\t\treturn err\n+\t}\n+\n+\tif err := t.Artifact.MarshalCBOR(cw); err != nil {\n+\t\treturn err\n+\t}\n+\n+\t// t.CreatedAt (string) (string)\n+\tif len(\"createdAt\") > 1000000 {\n+\t\treturn xerrors.Errorf(\"Value in field \\\"createdAt\\\" was too long\")\n+\t}\n+\n+\tif err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(\"createdAt\"))); err != nil {\n+\t\treturn err\n+\t}\n+\tif _, err := cw.WriteString(string(\"createdAt\")); err != nil {\n+\t\treturn err\n+\t}\n+\n+\tif len(t.CreatedAt) > 1000000 {\n+\t\treturn xerrors.Errorf(\"Value in field t.CreatedAt was too long\")\n+\t}\n+\n+\tif err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {\n+\t\treturn err\n+\t}\n+\tif _, err := cw.WriteString(string(t.CreatedAt)); err != nil {\n+\t\treturn err\n+\t}\n+\treturn nil\n+}\n+\n+func (t *RepoArtifact) UnmarshalCBOR(r io.Reader) (err error) {\n+\t*t = RepoArtifact{}\n+\n+\tcr := cbg.NewCborReader(r)\n+\n+\tmaj, extra, err := cr.ReadHeader()\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\tdefer func() {\n+\t\tif err == io.EOF {\n+\t\t\terr = io.ErrUnexpectedEOF\n+\t\t}\n+\t}()\n+\n+\tif maj != cbg.MajMap {\n+\t\treturn fmt.Errorf(\"cbor input should be of type map\")\n+\t}\n+\n+\tif extra > cbg.MaxLength {\n+\t\treturn fmt.Errorf(\"RepoArtifact: map struct too large (%d)\", extra)\n+\t}\n+\n+\tn := extra\n+\n+\tnameBuf := make([]byte, 9)\n+\tfor i := uint64(0); i < n; i++ {\n+\t\tnameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)\n+\t\tif err != nil {\n+\t\t\treturn err\n+\t\t}\n+\n+\t\tif !ok {\n+\t\t\t// Field doesn't exist on this type, so ignore it\n+\t\t\tif err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {\n+\t\t\t\treturn err\n+\t\t\t}\n+\t\t\tcontinue\n+\t\t}\n+\n+\t\tswitch string(nameBuf[:nameLen]) {\n+\t\t// t.Tag (util.LexBytes) (slice)\n+\t\tcase \"tag\":\n+\n+\t\t\tmaj, extra, err = cr.ReadHeader()\n+\t\t\tif err != nil {\n+\t\t\t\treturn err\n+\t\t\t}\n+\n+\t\t\tif extra > 2097152 {\n+\t\t\t\treturn fmt.Errorf(\"t.Tag: byte array too large (%d)\", extra)\n+\t\t\t}\n+\t\t\tif maj != cbg.MajByteString {\n+\t\t\t\treturn fmt.Errorf(\"expected byte array\")\n+\t\t\t}\n+\n+\t\t\tif extra > 0 {\n+\t\t\t\tt.Tag = make([]uint8, extra)\n+\t\t\t}\n+\n+\t\t\tif _, err := io.ReadFull(cr, t.Tag); err != nil {\n+\t\t\t\treturn err\n+\t\t\t}\n+\n+\t\t\t// t.Name (string) (string)\n+\t\tcase \"name\":\n+\n+\t\t\t{\n+\t\t\t\tsval, err := cbg.ReadStringWithMax(cr, 1000000)\n+\t\t\t\tif err != nil {\n+\t\t\t\t\treturn err\n+\t\t\t\t}\n+\n+\t\t\t\tt.Name = string(sval)\n+\t\t\t}\n+\t\t\t// t.Repo (string) (string)\n+\t\tcase \"repo\":\n+\n+\t\t\t{\n+\t\t\t\tsval, err := cbg.ReadStringWithMax(cr, 1000000)\n+\t\t\t\tif err != nil {\n+\t\t\t\t\treturn err\n+\t\t\t\t}\n+\n+\t\t\t\tt.Repo = string(sval)\n+\t\t\t}\n+\t\t\t// t.LexiconTypeID (string) (string)\n+\t\tcase \"$type\":\n+\n+\t\t\t{\n+\t\t\t\tsval, err := cbg.ReadStringWithMax(cr, 1000000)\n+\t\t\t\tif err != nil {\n+\t\t\t\t\treturn err\n+\t\t\t\t}\n+\n+\t\t\t\tt.LexiconTypeID = string(sval)\n+\t\t\t}\n+\t\t\t// t.Artifact (util.LexBlob) (struct)\n+\t\tcase \"artifact\":\n+\n+\t\t\t{\n+\n+\t\t\t\tb, err := cr.ReadByte()\n+\t\t\t\tif err != nil {\n+\t\t\t\t\treturn err\n+\t\t\t\t}\n+\t\t\t\tif b != cbg.CborNull[0] {\n+\t\t\t\t\tif err := cr.UnreadByte(); err != nil {\n+\t\t\t\t\t\treturn err\n+\t\t\t\t\t}\n+\t\t\t\t\tt.Artifact = new(util.LexBlob)\n+\t\t\t\t\tif err := t.Artifact.UnmarshalCBOR(cr); err != nil {\n+\t\t\t\t\t\treturn xerrors.Errorf(\"unmarshaling t.Artifact pointer: %w\", err)\n+\t\t\t\t\t}\n+\t\t\t\t}\n+\n+\t\t\t}\n+\t\t\t// t.CreatedAt (string) (string)\n+\t\tcase \"createdAt\":\n+\n+\t\t\t{\n+\t\t\t\tsval, err := cbg.ReadStringWithMax(cr, 1000000)\n+\t\t\t\tif err != nil {\n+\t\t\t\t\treturn err\n+\t\t\t\t}\n+\n+\t\t\t\tt.CreatedAt = string(sval)\n+\t\t\t}\n+\n+\t\tdefault:\n+\t\t\t// Field doesn't exist on this type, so ignore it\n+\t\t\tif err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {\n+\t\t\t\treturn err\n+\t\t\t}\n+\t\t}\n+\t}\n+\n+\treturn nil\n+}\ndiff --git a/api/tangled/repoartifact.go b/api/tangled/repoartifact.go\nnew file mode 100644\nindex 0000000..e546d96\n--- /dev/null\n+++ b/api/tangled/repoartifact.go\n@@ -0,0 +1,31 @@\n+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.\n+\n+package tangled\n+\n+// schema: sh.tangled.repo.artifact\n+\n+import (\n+\t\"github.com/bluesky-social/indigo/lex/util\"\n+)\n+\n+const (\n+\tRepoArtifactNSID = \"sh.tangled.repo.artifact\"\n+)\n+\n+func init() {\n+\tutil.RegisterType(\"sh.tangled.repo.artifact\", &RepoArtifact{})\n+} //\n+// RECORDTYPE: RepoArtifact\n+type RepoArtifact struct {\n+\tLexiconTypeID string `json:\"$type,const=sh.tangled.repo.artifact\" cborgen:\"$type,const=sh.tangled.repo.artifact\"`\n+\t// artifact: the artifact\n+\tArtifact *util.LexBlob `json:\"artifact\" cborgen:\"artifact\"`\n+\t// createdAt: time of creation of this artifact\n+\tCreatedAt string `json:\"createdAt\" cborgen:\"createdAt\"`\n+\t// name: name of the artifact\n+\tName string `json:\"name\" cborgen:\"name\"`\n+\t// repo: repo that this artifact is being uploaded to\n+\tRepo string `json:\"repo\" cborgen:\"repo\"`\n+\t// tag: hash of the tag object that this artifact is attached to (only annotated tags are supported)\n+\tTag util.LexBytes `json:\"tag,omitempty\" cborgen:\"tag,omitempty\"`\n+}\ndiff --git a/appview/db/artifact.go b/appview/db/artifact.go\nnew file mode 100644\nindex 0000000..df77a55\n--- /dev/null\n+++ b/appview/db/artifact.go\n@@ -0,0 +1,166 @@\n+package db\n+\n+import (\n+\t\"fmt\"\n+\t\"strings\"\n+\t\"time\"\n+\n+\t\"github.com/bluesky-social/indigo/atproto/syntax\"\n+\t\"github.com/go-git/go-git/v5/plumbing\"\n+\t\"github.com/ipfs/go-cid\"\n+\t\"tangled.sh/tangled.sh/core/api/tangled\"\n+)\n+\n+type Artifact struct {\n+\tId   uint64\n+\tDid  string\n+\tRkey string\n+\n+\tRepoAt    syntax.ATURI\n+\tTag       plumbing.Hash\n+\tCreatedAt time.Time\n+\n+\tBlobCid  cid.Cid\n+\tName     string\n+\tSize     uint64\n+\tMimetype string\n+}\n+\n+func (a *Artifact) ArtifactAt() syntax.ATURI {\n+\treturn syntax.ATURI(fmt.Sprintf(\"at://%s/%s/%s\", a.Did, tangled.RepoPullNSID, a.Rkey))\n+}\n+\n+func AddArtifact(e Execer, artifact Artifact) error {\n+\t_, err := e.Exec(\n+\t\t`insert or ignore into artifacts (\n+\t\t\tdid,\n+\t\t\trkey,\n+\t\t\trepo_at,\n+\t\t\ttag,\n+\t\t\tcreated,\n+\t\t\tblob_cid,\n+\t\t\tname,\n+\t\t\tsize,\n+\t\t\tmimetype\n+\t\t)\n+\t\tvalues (?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n+\t\tartifact.Did,\n+\t\tartifact.Rkey,\n+\t\tartifact.RepoAt,\n+\t\tartifact.Tag[:],\n+\t\tartifact.CreatedAt.Format(time.RFC3339),\n+\t\tartifact.BlobCid.String(),\n+\t\tartifact.Name,\n+\t\tartifact.Size,\n+\t\tartifact.Mimetype,\n+\t)\n+\treturn err\n+}\n+\n+type Filter struct {\n+\tkey string\n+\targ any\n+}\n+\n+func NewFilter(key string, arg any) Filter {\n+\treturn Filter{\n+\t\tkey: key,\n+\t\targ: arg,\n+\t}\n+}\n+\n+func (f Filter) Condition() string {\n+\treturn fmt.Sprintf(\"%s = ?\", f.key)\n+}\n+\n+func GetArtifact(e Execer, filters ...Filter) ([]Artifact, error) {\n+\tvar artifacts []Artifact\n+\n+\tvar conditions []string\n+\tvar args []any\n+\tfor _, filter := range filters {\n+\t\tconditions = append(conditions, filter.Condition())\n+\t\targs = append(args, filter.arg)\n+\t}\n+\n+\twhereClause := \"\"\n+\tif conditions != nil {\n+\t\twhereClause = \" where \" + strings.Join(conditions, \" and \")\n+\t}\n+\n+\tquery := fmt.Sprintf(`select\n+\t\t\tdid,\n+\t\t\trkey,\n+\t\t\trepo_at,\n+\t\t\ttag,\n+\t\t\tcreated,\n+\t\t\tblob_cid,\n+\t\t\tname,\n+\t\t\tsize,\n+\t\t\tmimetype\n+\t\tfrom artifacts %s`,\n+\t\twhereClause,\n+\t)\n+\n+\trows, err := e.Query(query, args...)\n+\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\tdefer rows.Close()\n+\n+\tfor rows.Next() {\n+\t\tvar artifact Artifact\n+\t\tvar createdAt string\n+\t\tvar tag []byte\n+\t\tvar blobCid string\n+\n+\t\tif err := rows.Scan(\n+\t\t\t&artifact.Did,\n+\t\t\t&artifact.Rkey,\n+\t\t\t&artifact.RepoAt,\n+\t\t\t&tag,\n+\t\t\t&createdAt,\n+\t\t\t&blobCid,\n+\t\t\t&artifact.Name,\n+\t\t\t&artifact.Size,\n+\t\t\t&artifact.Mimetype,\n+\t\t); err != nil {\n+\t\t\treturn nil, err\n+\t\t}\n+\n+\t\tartifact.CreatedAt, err = time.Parse(time.RFC3339, createdAt)\n+\t\tif err != nil {\n+\t\t\tartifact.CreatedAt = time.Now()\n+\t\t}\n+\t\tartifact.Tag = plumbing.Hash(tag)\n+\t\tartifact.BlobCid = cid.MustParse(blobCid)\n+\n+\t\tartifacts = append(artifacts, artifact)\n+\t}\n+\n+\tif err := rows.Err(); err != nil {\n+\t\treturn nil, err\n+\t}\n+\n+\treturn artifacts, nil\n+}\n+\n+func RemoveArtifact(e Execer, filters ...Filter) error {\n+\tvar conditions []string\n+\tvar args []any\n+\tfor _, filter := range filters {\n+\t\tconditions = append(conditions, filter.Condition())\n+\t\targs = append(args, filter.arg)\n+\t}\n+\n+\twhereClause := \"\"\n+\tif conditions != nil {\n+\t\twhereClause = \" where \" + strings.Join(conditions, \" and \")\n+\t}\n+\n+\tquery := fmt.Sprintf(`delete from artifacts %s`, whereClause)\n+\n+\t_, err := e.Exec(query, args...)\n+\treturn err\n+}\ndiff --git a/appview/db/db.go b/appview/db/db.go\nindex 2c3ed40..0b74db7 100644\n--- a/appview/db/db.go\n+++ b/appview/db/db.go\n@@ -208,6 +208,29 @@ func Make(dbPath string) (*DB, error) {\n \t\t\tunique(did, email)\n \t\t);\n \n+\t\tcreate table if not exists artifacts (\n+\t\t\t-- id\n+\t\t\tid integer primary key autoincrement,\n+\t\t\tdid text not null,\n+\t\t\trkey text not null,\n+\n+\t\t\t-- meta\n+\t\t\trepo_at text not null,\n+\t\t\ttag binary(20) not null,\n+\t\t\tcreated text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),\n+\n+\t\t\t-- data\n+\t\t\tblob_cid text not null,\n+\t\t\tname text not null,\n+\t\t\tsize integer not null default 0,\n+\t\t\tmimetype string not null default \"*/*\",\n+\n+\t\t\t-- constraints\n+\t\t\tunique(did, rkey),          -- record must be unique\n+\t\t\tunique(repo_at, tag, name), -- for a given tag object, each file must be unique\n+\t\t\tforeign key (repo_at) references repos(at_uri) on delete cascade\n+\t\t);\n+\n \t\tcreate table if not exists migrations (\n \t\t\tid integer primary key autoincrement,\n \t\t\tname text unique\ndiff --git a/appview/db/pulls.go b/appview/db/pulls.go\nindex 69efee4..da6e086 100644\n--- a/appview/db/pulls.go\n+++ b/appview/db/pulls.go\n@@ -10,7 +10,7 @@ import (\n \n \t\"github.com/bluekeyes/go-gitdiff/gitdiff\"\n \t\"github.com/bluesky-social/indigo/atproto/syntax\"\n-\ttangled \"tangled.sh/tangled.sh/core/api/tangled\"\n+\t\"tangled.sh/tangled.sh/core/api/tangled\"\n \t\"tangled.sh/tangled.sh/core/patchutil\"\n \t\"tangled.sh/tangled.sh/core/types\"\n )\ndiff --git a/appview/pages/pages.go b/appview/pages/pages.go\nindex 23fb520..40a39a4 100644\n--- a/appview/pages/pages.go\n+++ b/appview/pages/pages.go\n@@ -28,6 +28,7 @@ import (\n \t\"github.com/alecthomas/chroma/v2/lexers\"\n \t\"github.com/alecthomas/chroma/v2/styles\"\n \t\"github.com/bluesky-social/indigo/atproto/syntax\"\n+\t\"github.com/go-git/go-git/v5/plumbing\"\n \t\"github.com/go-git/go-git/v5/plumbing/object\"\n \t\"github.com/microcosm-cc/bluemonday\"\n )\n@@ -484,6 +485,8 @@ type RepoTagsParams struct {\n \tRepoInfo     repoinfo.RepoInfo\n \tActive       string\n \ttypes.RepoTagsResponse\n+\tArtifactMap       map[plumbing.Hash][]db.Artifact\n+\tDanglingArtifacts []db.Artifact\n }\n \n func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {\n@@ -491,6 +494,16 @@ func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {\n \treturn p.executeRepo(\"repo/tags\", w, params)\n }\n \n+type RepoArtifactParams struct {\n+\tLoggedInUser *auth.User\n+\tRepoInfo     RepoInfo\n+\tArtifact     db.Artifact\n+}\n+\n+func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {\n+\treturn p.executePlain(\"repo/fragments/artifact\", w, params)\n+}\n+\n type RepoBlobParams struct {\n \tLoggedInUser     *auth.User\n \tRepoInfo         repoinfo.RepoInfo\ndiff --git a/appview/pages/templates/repo/fragments/artifact.html b/appview/pages/templates/repo/fragments/artifact.html\nnew file mode 100644\nindex 0000000..3e0a4ba\n--- /dev/null\n+++ b/appview/pages/templates/repo/fragments/artifact.html\n@@ -0,0 +1,34 @@\n+{{ define \"repo/fragments/artifact\" }}\n+{{ $unique := .Artifact.BlobCid.String }}\n+  <div id=\"artifact-{{ $unique }}\" class=\"flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700\">\n+      <div id=\"left-side\" class=\"flex items-center gap-2 min-w-0 max-w-[60%]\">\n+        {{ i \"box\" \"w-4 h-4\" }}\n+        <a href=\"/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}\" class=\"no-underline hover:no-underline\">\n+          {{ .Artifact.Name }}\n+        </a>\n+        <span class=\"text-gray-500 dark:text-gray-400 pl-2\">{{ byteFmt .Artifact.Size }}</span>\n+      </div>\n+\n+    <div id=\"right-side\" class=\"text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2\">\n+      <span title=\"{{ longTimeFmt .Artifact.CreatedAt }}\" class=\"hidden md:inline\">{{ timeFmt .Artifact.CreatedAt }}</span>\n+      <span title=\"{{ longTimeFmt .Artifact.CreatedAt }}\" class=\"       md:hidden\">{{ shortTimeFmt .Artifact.CreatedAt }}</span>\n+\n+      <span class=\"select-none after:content-['·'] hidden md:inline\"></span>\n+      <span class=\"truncate max-w-[100px] hidden md:inline\">{{ .Artifact.Mimetype }}</span>\n+\n+      {{ if and (.LoggedInUser) (eq .LoggedInUser.Did .Artifact.Did) }}\n+        <button\n+          id=\"delete-{{ $unique }}\"\n+          class=\"btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2\"\n+          title=\"Delete artifact\"\n+          hx-delete=\"/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/{{ .Artifact.Name | urlquery }}\"\n+          hx-swap=\"outerHTML\"\n+          hx-target=\"#artifact-{{ $unique }}\"\n+          hx-disabled-elt=\"#delete-{{ $unique }}\"\n+          hx-confirm=\"Are you sure you want to delete the artifact '{{ .Artifact.Name }}'?\">\n+          {{ i \"trash-2\" \"w-4 h-4\" }}\n+        </button>\n+      {{ end }}\n+    </div>\n+  </div>\n+{{ end }}\ndiff --git a/appview/pages/templates/repo/tags.html b/appview/pages/templates/repo/tags.html\nindex 0dc2fd7..3faba99 100644\n--- a/appview/pages/templates/repo/tags.html\n+++ b/appview/pages/templates/repo/tags.html\n@@ -11,7 +11,7 @@\n       <!-- Header column (top on mobile, left on md+) -->\n       <div class=\"md:col-span-2 md:border-r border-b md:border-b-0 border-gray-200 dark:border-gray-700 w-full md:h-full\">\n         <!-- Mobile layout: horizontal -->\n-        <div class=\"flex md:hidden flex-col py-2 px-2\">\n+        <div class=\"flex md:hidden flex-col py-2 px-2 text-xl\">\n           <a href=\"/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}\" class=\"no-underline hover:underline flex items-center gap-2 font-bold\">\n             {{ i \"tag\" \"w-4 h-4\" }}\n             {{ .Name }}\n@@ -54,13 +54,14 @@\n       </div>\n \n       <!-- Content column (bottom on mobile, right on md+) -->\n-      <div class=\"md:col-span-9 px-2 py-3 md:py-0 md:pb-6\">\n+      <div class=\"md:col-span-10 px-2 py-3 md:py-0 md:pb-6\">\n         {{ if .Tag }}\n           {{ $messageParts := splitN .Tag.Message \"\\n\\n\" 2 }}\n-          <p class=\"font-bold\">{{ index $messageParts 0 }}</p>\n+          <p class=\"font-bold text-lg\">{{ index $messageParts 0 }}</p>\n           {{ if gt (len $messageParts) 1 }}\n-            <p class=\"cursor-text pb-2 text-sm\">{{ nl2br (index $messageParts 1) }}</p>\n+            <p class=\"cursor-text py-2\">{{ nl2br (index $messageParts 1) }}</p>\n           {{ end }}\n+          {{ block \"artifacts\" (list $ .) }} {{ end }}\n         {{ else }}\n           <p class=\"italic text-gray-500 dark:text-gray-400\">no message</p>\n         {{ end }}\n@@ -74,3 +75,91 @@\n   </div>\n </section>\n {{ end }}\n+\n+{{ define \"repoAfter\" }}\n+{{ if gt (len .DanglingArtifacts) 0 }}\n+  <section class=\"bg-white dark:bg-gray-800 p-6 mt-4\">\n+    {{ block \"dangling\" . }} {{ end }}\n+  </section>\n+{{ end }}\n+{{ end }}\n+\n+{{ define \"artifacts\" }}\n+  {{ $root := index . 0 }}\n+  {{ $tag := index . 1 }}\n+  {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }}\n+  {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }}\n+\n+  {{ if or (gt (len $artifacts) 0) $isPushAllowed }}\n+  <h2 class=\"my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold\">artifacts</h2>\n+  <div class=\"flex flex-col rounded border border-gray-200 dark:border-gray-700\">\n+    {{ range $artifact := $artifacts }}\n+      {{ $args := dict \"LoggedInUser\" $root.LoggedInUser \"RepoInfo\" $root.RepoInfo \"Artifact\" $artifact }}\n+      {{ template \"repo/fragments/artifact\" $args }}\n+    {{ end }}\n+    {{ if $isPushAllowed }}\n+      {{ block \"uploadArtifact\" (list $root $tag) }} {{ end }}\n+    {{ end }}\n+  </div>\n+  {{ end }}\n+{{ end }}\n+\n+{{ define \"uploadArtifact\" }}\n+{{ $root := index . 0 }}\n+{{ $tag := index . 1 }}\n+{{ $unique := $tag.Tag.Target.String }}\n+  <form\n+    id=\"upload-{{$unique}}\"\n+    method=\"post\"\n+    enctype=\"multipart/form-data\"\n+    hx-post=\"/{{ $root.RepoInfo.FullName }}/tags/{{ $tag.Name | urlquery }}/upload\"\n+    hx-on::after-request=\"if(event.detail.successful) this.reset()\"\n+    hx-disabled-elt=\"#upload-btn-{{$unique}}\"\n+    hx-swap=\"beforebegin\"\n+    hx-target=\"this\"\n+    class=\"flex items-center gap-2 px-2\">\n+    <div class=\"flex-grow\">\n+      <input type=\"file\" \n+        name=\"artifact\" \n+        required\n+        class=\"block py-2 px-0 w-full border-none\n+        text-black dark:text-white\n+        bg-white dark:bg-gray-800\n+        file:mr-4 file:px-2 file:py-2\n+        file:rounded file:border-0\n+        file:text-sm file:font-medium\n+        file:text-gray-700 file:dark:text-gray-300\n+        file:bg-gray-200 file:dark:bg-gray-700\n+        file:hover:bg-gray-100 file:hover:dark:bg-gray-600\n+        \">\n+      </input>\n+    </div>\n+    <div class=\"flex justify-end\">\n+      <button \n+        type=\"submit\" \n+        class=\"btn gap-2\" \n+        id=\"upload-btn-{{$unique}}\"\n+        title=\"Upload artifact\">\n+        {{ i \"upload\" \"w-4 h-4\" }}\n+        <span class=\"hidden md:inline\">upload</span> \n+      </button>\n+    </div>\n+  </form>\n+{{ end }}\n+\n+{{ define \"dangling\" }}\n+  {{ $root := . }}\n+  {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }}\n+  {{ $artifacts := $root.DanglingArtifacts }}\n+\n+  {{ if and (gt (len $artifacts) 0) $isPushAllowed }}\n+  <h2 class=\"mb-2 text-sm text-left text-red-700 dark:text-red-400 uppercase font-bold\">dangling artifacts</h2>\n+  <p class=\"mb-4\">The tags that these artifacts were attached to have been deleted. These artifacts are only visible to collaborators.</p>\n+  <div class=\"flex flex-col rounded border border-gray-200 dark:border-gray-700\">\n+    {{ range $artifact := $artifacts }}\n+      {{ $args := dict \"LoggedInUser\" $root.LoggedInUser \"RepoInfo\" $root.RepoInfo \"Artifact\" $artifact }}\n+      {{ template \"repo/fragments/artifact\" $args }}\n+    {{ end }}\n+  </div>\n+  {{ end }}\n+{{ end }}\ndiff --git a/appview/state/artifact.go b/appview/state/artifact.go\nnew file mode 100644\nindex 0000000..6caca70\n--- /dev/null\n+++ b/appview/state/artifact.go\n@@ -0,0 +1,280 @@\n+package state\n+\n+import (\n+\t\"fmt\"\n+\t\"log\"\n+\t\"net/http\"\n+\t\"time\"\n+\n+\tcomatproto \"github.com/bluesky-social/indigo/api/atproto\"\n+\tlexutil \"github.com/bluesky-social/indigo/lex/util\"\n+\t\"github.com/dustin/go-humanize\"\n+\t\"github.com/go-chi/chi/v5\"\n+\t\"github.com/go-git/go-git/v5/plumbing\"\n+\t\"github.com/ipfs/go-cid\"\n+\t\"tangled.sh/tangled.sh/core/api/tangled\"\n+\t\"tangled.sh/tangled.sh/core/appview\"\n+\t\"tangled.sh/tangled.sh/core/appview/db\"\n+\t\"tangled.sh/tangled.sh/core/appview/pages\"\n+\t\"tangled.sh/tangled.sh/core/types\"\n+)\n+\n+// TODO: proper statuses here on early exit\n+func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) {\n+\tuser := s.auth.GetUser(r)\n+\ttagParam := chi.URLParam(r, \"tag\")\n+\tf, err := fullyResolvedRepo(r)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to get repo and knot\", err)\n+\t\ts.pages.Notice(w, \"upload\", \"failed to upload artifact, error in repo resolution\")\n+\t\treturn\n+\t}\n+\n+\ttag, err := s.resolveTag(f, tagParam)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to resolve tag\", err)\n+\t\ts.pages.Notice(w, \"upload\", \"failed to upload artifact, error in tag resolution\")\n+\t\treturn\n+\t}\n+\n+\tfile, handler, err := r.FormFile(\"artifact\")\n+\tif err != nil {\n+\t\tlog.Println(\"failed to upload artifact\", err)\n+\t\ts.pages.Notice(w, \"upload\", \"failed to upload artifact\")\n+\t\treturn\n+\t}\n+\tdefer file.Close()\n+\n+\tclient, _ := s.auth.AuthorizedClient(r)\n+\n+\tuploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to upload blob\", err)\n+\t\ts.pages.Notice(w, \"upload\", \"Failed to upload blob to your PDS. Try again later.\")\n+\t\treturn\n+\t}\n+\n+\tlog.Println(\"uploaded blob\", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String())\n+\n+\trkey := appview.TID()\n+\tcreatedAt := time.Now()\n+\n+\tputRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{\n+\t\tCollection: tangled.RepoArtifactNSID,\n+\t\tRepo:       user.Did,\n+\t\tRkey:       rkey,\n+\t\tRecord: &lexutil.LexiconTypeDecoder{\n+\t\t\tVal: &tangled.RepoArtifact{\n+\t\t\t\tArtifact:  uploadBlobResp.Blob,\n+\t\t\t\tCreatedAt: createdAt.Format(time.RFC3339),\n+\t\t\t\tName:      handler.Filename,\n+\t\t\t\tRepo:      f.RepoAt.String(),\n+\t\t\t\tTag:       tag.Tag.Hash[:],\n+\t\t\t},\n+\t\t},\n+\t})\n+\tif err != nil {\n+\t\tlog.Println(\"failed to create record\", err)\n+\t\ts.pages.Notice(w, \"upload\", \"Failed to create artifact record. Try again later.\")\n+\t\treturn\n+\t}\n+\n+\tlog.Println(putRecordResp.Uri)\n+\n+\ttx, err := s.db.BeginTx(r.Context(), nil)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to start tx\")\n+\t\ts.pages.Notice(w, \"upload\", \"Failed to create artifact. Try again later.\")\n+\t\treturn\n+\t}\n+\tdefer tx.Rollback()\n+\n+\tartifact := db.Artifact{\n+\t\tDid:       user.Did,\n+\t\tRkey:      rkey,\n+\t\tRepoAt:    f.RepoAt,\n+\t\tTag:       tag.Tag.Hash,\n+\t\tCreatedAt: createdAt,\n+\t\tBlobCid:   cid.Cid(uploadBlobResp.Blob.Ref),\n+\t\tName:      handler.Filename,\n+\t\tSize:      uint64(uploadBlobResp.Blob.Size),\n+\t\tMimetype:  uploadBlobResp.Blob.MimeType,\n+\t}\n+\n+\terr = db.AddArtifact(tx, artifact)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to add artifact record to db\", err)\n+\t\ts.pages.Notice(w, \"upload\", \"Failed to create artifact. Try again later.\")\n+\t\treturn\n+\t}\n+\n+\terr = tx.Commit()\n+\tif err != nil {\n+\t\tlog.Println(\"failed to add artifact record to db\")\n+\t\ts.pages.Notice(w, \"upload\", \"Failed to create artifact. Try again later.\")\n+\t\treturn\n+\t}\n+\n+\ts.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{\n+\t\tLoggedInUser: user,\n+\t\tRepoInfo:     f.RepoInfo(s, user),\n+\t\tArtifact:     artifact,\n+\t})\n+}\n+\n+// TODO: proper statuses here on early exit\n+func (s *State) DownloadArtifact(w http.ResponseWriter, r *http.Request) {\n+\ttagParam := chi.URLParam(r, \"tag\")\n+\tfilename := chi.URLParam(r, \"file\")\n+\tf, err := fullyResolvedRepo(r)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to get repo and knot\", err)\n+\t\treturn\n+\t}\n+\n+\ttag, err := s.resolveTag(f, tagParam)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to resolve tag\", err)\n+\t\ts.pages.Notice(w, \"upload\", \"failed to upload artifact, error in tag resolution\")\n+\t\treturn\n+\t}\n+\n+\tclient, _ := s.auth.AuthorizedClient(r)\n+\n+\tartifacts, err := db.GetArtifact(\n+\t\ts.db,\n+\t\tdb.NewFilter(\"repo_at\", f.RepoAt),\n+\t\tdb.NewFilter(\"tag\", tag.Tag.Hash[:]),\n+\t\tdb.NewFilter(\"name\", filename),\n+\t)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to get artifacts\", err)\n+\t\treturn\n+\t}\n+\tif len(artifacts) != 1 {\n+\t\tlog.Printf(\"too many or too little artifacts found\")\n+\t\treturn\n+\t}\n+\n+\tartifact := artifacts[0]\n+\n+\tgetBlobResp, err := comatproto.SyncGetBlob(r.Context(), client, artifact.BlobCid.String(), artifact.Did)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to get blob from pds\", err)\n+\t\treturn\n+\t}\n+\n+\tw.Header().Set(\"Content-Disposition\", fmt.Sprintf(\"attachment; filename=%q\", filename))\n+\tw.Write(getBlobResp)\n+}\n+\n+// TODO: proper statuses here on early exit\n+func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) {\n+\tuser := s.auth.GetUser(r)\n+\ttagParam := chi.URLParam(r, \"tag\")\n+\tfilename := chi.URLParam(r, \"file\")\n+\tf, err := fullyResolvedRepo(r)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to get repo and knot\", err)\n+\t\treturn\n+\t}\n+\n+\tclient, _ := s.auth.AuthorizedClient(r)\n+\n+\ttag := plumbing.NewHash(tagParam)\n+\n+\tartifacts, err := db.GetArtifact(\n+\t\ts.db,\n+\t\tdb.NewFilter(\"repo_at\", f.RepoAt),\n+\t\tdb.NewFilter(\"tag\", tag[:]),\n+\t\tdb.NewFilter(\"name\", filename),\n+\t)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to get artifacts\", err)\n+\t\ts.pages.Notice(w, \"remove\", \"Failed to delete artifact. Try again later.\")\n+\t\treturn\n+\t}\n+\tif len(artifacts) != 1 {\n+\t\ts.pages.Notice(w, \"remove\", \"Unable to find artifact.\")\n+\t\treturn\n+\t}\n+\n+\tartifact := artifacts[0]\n+\n+\tif user.Did != artifact.Did {\n+\t\tlog.Println(\"user not authorized to delete artifact\", err)\n+\t\ts.pages.Notice(w, \"remove\", \"Unauthorized deletion of artifact.\")\n+\t\treturn\n+\t}\n+\n+\t_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{\n+\t\tCollection: tangled.RepoArtifactNSID,\n+\t\tRepo:       user.Did,\n+\t\tRkey:       artifact.Rkey,\n+\t})\n+\tif err != nil {\n+\t\tlog.Println(\"failed to get blob from pds\", err)\n+\t\ts.pages.Notice(w, \"remove\", \"Failed to remove blob from PDS.\")\n+\t\treturn\n+\t}\n+\n+\ttx, err := s.db.BeginTx(r.Context(), nil)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to start tx\")\n+\t\ts.pages.Notice(w, \"remove\", \"Failed to delete artifact. Try again later.\")\n+\t\treturn\n+\t}\n+\tdefer tx.Rollback()\n+\n+\terr = db.RemoveArtifact(tx,\n+\t\tdb.NewFilter(\"repo_at\", f.RepoAt),\n+\t\tdb.NewFilter(\"tag\", artifact.Tag[:]),\n+\t\tdb.NewFilter(\"name\", filename),\n+\t)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to remove artifact record from db\", err)\n+\t\ts.pages.Notice(w, \"remove\", \"Failed to delete artifact. Try again later.\")\n+\t\treturn\n+\t}\n+\n+\terr = tx.Commit()\n+\tif err != nil {\n+\t\tlog.Println(\"failed to remove artifact record from db\")\n+\t\ts.pages.Notice(w, \"remove\", \"Failed to delete artifact. Try again later.\")\n+\t\treturn\n+\t}\n+\n+\tw.Write([]byte{})\n+}\n+\n+func (s *State) resolveTag(f *FullyResolvedRepo, tagParam string) (*types.TagReference, error) {\n+\tus, err := NewUnsignedClient(f.Knot, s.config.Dev)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\n+\tresult, err := us.Tags(f.OwnerDid(), f.RepoName)\n+\tif err != nil {\n+\t\tlog.Println(\"failed to reach knotserver\", err)\n+\t\treturn nil, err\n+\t}\n+\n+\tvar tag *types.TagReference\n+\tfor _, t := range result.Tags {\n+\t\tif t.Tag != nil {\n+\t\t\tif t.Reference.Name == tagParam || t.Reference.Hash == tagParam {\n+\t\t\t\ttag = t\n+\t\t\t}\n+\t\t}\n+\t}\n+\n+\tif tag == nil {\n+\t\treturn nil, fmt.Errorf(\"invalid tag, only annotated tags are supported for artifacts\")\n+\t}\n+\n+\tif tag.Tag.Target.IsZero() {\n+\t\treturn nil, fmt.Errorf(\"invalid tag, only annotated tags are supported for artifacts\")\n+\t}\n+\n+\treturn tag, nil\n+}\ndiff --git a/appview/state/follow.go b/appview/state/follow.go\nindex a0fa32d..74b5f5a 100644\n--- a/appview/state/follow.go\n+++ b/appview/state/follow.go\n@@ -7,7 +7,7 @@ import (\n \n \tcomatproto \"github.com/bluesky-social/indigo/api/atproto\"\n \tlexutil \"github.com/bluesky-social/indigo/lex/util\"\n-\ttangled \"tangled.sh/tangled.sh/core/api/tangled\"\n+\t\"tangled.sh/tangled.sh/core/api/tangled\"\n \t\"tangled.sh/tangled.sh/core/appview\"\n \t\"tangled.sh/tangled.sh/core/appview/db\"\n \t\"tangled.sh/tangled.sh/core/appview/pages\"\ndiff --git a/appview/state/jetstream.go b/appview/state/jetstream.go\nnew file mode 100644\nindex 0000000..99bc009\n--- /dev/null\n+++ b/appview/state/jetstream.go\n@@ -0,0 +1,70 @@\n+package state\n+\n+import (\n+\t\"context\"\n+\t\"encoding/json\"\n+\t\"fmt\"\n+\t\"log\"\n+\n+\t\"github.com/bluesky-social/indigo/atproto/syntax\"\n+\t\"github.com/bluesky-social/jetstream/pkg/models\"\n+\t\"tangled.sh/tangled.sh/core/api/tangled\"\n+\t\"tangled.sh/tangled.sh/core/appview/db\"\n+)\n+\n+type Ingester func(ctx context.Context, e *models.Event) error\n+\n+func jetstreamIngester(d db.DbWrapper) Ingester {\n+\treturn func(ctx context.Context, e *models.Event) error {\n+\t\tvar err error\n+\t\tdefer func() {\n+\t\t\teventTime := e.TimeUS\n+\t\t\tlastTimeUs := eventTime + 1\n+\t\t\tif err := d.SaveLastTimeUs(lastTimeUs); err != nil {\n+\t\t\t\terr = fmt.Errorf(\"(deferred) failed to save last time us: %w\", err)\n+\t\t\t}\n+\t\t}()\n+\n+\t\tif e.Kind != models.EventKindCommit {\n+\t\t\treturn nil\n+\t\t}\n+\n+\t\tdid := e.Did\n+\t\traw := json.RawMessage(e.Commit.Record)\n+\n+\t\tswitch e.Commit.Collection {\n+\t\tcase tangled.GraphFollowNSID:\n+\t\t\trecord := tangled.GraphFollow{}\n+\t\t\terr := json.Unmarshal(raw, &record)\n+\t\t\tif err != nil {\n+\t\t\t\tlog.Println(\"invalid record\")\n+\t\t\t\treturn err\n+\t\t\t}\n+\t\t\terr = db.AddFollow(d, did, record.Subject, e.Commit.RKey)\n+\t\t\tif err != nil {\n+\t\t\t\treturn fmt.Errorf(\"failed to add follow to db: %w\", err)\n+\t\t\t}\n+\t\tcase tangled.FeedStarNSID:\n+\t\t\trecord := tangled.FeedStar{}\n+\t\t\terr := json.Unmarshal(raw, &record)\n+\t\t\tif err != nil {\n+\t\t\t\tlog.Println(\"invalid record\")\n+\t\t\t\treturn err\n+\t\t\t}\n+\n+\t\t\tsubjectUri, err := syntax.ParseATURI(record.Subject)\n+\n+\t\t\tif err != nil {\n+\t\t\t\tlog.Println(\"invalid record\")\n+\t\t\t\treturn err\n+\t\t\t}\n+\n+\t\t\terr = db.AddStar(d, did, subjectUri, e.Commit.RKey)\n+\t\t\tif err != nil {\n+\t\t\t\treturn fmt.Errorf(\"failed to add follow to db: %w\", err)\n+\t\t\t}\n+\t\t}\n+\n+\t\treturn err\n+\t}\n+}\ndiff --git a/appview/state/repo.go b/appview/state/repo.go\nindex f1209ab..93547e4 100644\n--- a/appview/state/repo.go\n+++ b/appview/state/repo.go\n@@ -16,12 +16,6 @@ import (\n \t\"strings\"\n \t\"time\"\n \n-\t\"github.com/bluesky-social/indigo/atproto/data\"\n-\t\"github.com/bluesky-social/indigo/atproto/identity\"\n-\t\"github.com/bluesky-social/indigo/atproto/syntax\"\n-\tsecurejoin \"github.com/cyphar/filepath-securejoin\"\n-\t\"github.com/go-chi/chi/v5\"\n-\t\"github.com/go-git/go-git/v5/plumbing\"\n \t\"tangled.sh/tangled.sh/core/api/tangled\"\n \t\"tangled.sh/tangled.sh/core/appview\"\n \t\"tangled.sh/tangled.sh/core/appview/auth\"\n@@ -32,6 +26,13 @@ import (\n \t\"tangled.sh/tangled.sh/core/appview/pagination\"\n \t\"tangled.sh/tangled.sh/core/types\"\n \n+\t\"github.com/bluesky-social/indigo/atproto/data\"\n+\t\"github.com/bluesky-social/indigo/atproto/identity\"\n+\t\"github.com/bluesky-social/indigo/atproto/syntax\"\n+\tsecurejoin \"github.com/cyphar/filepath-securejoin\"\n+\t\"github.com/go-chi/chi/v5\"\n+\t\"github.com/go-git/go-git/v5/plumbing\"\n+\n \tcomatproto \"github.com/bluesky-social/indigo/api/atproto\"\n \tlexutil \"github.com/bluesky-social/indigo/lex/util\"\n )\n@@ -171,25 +172,12 @@ func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {\n \t\treturn\n \t}\n \n-\tresp, err = us.Tags(f.OwnerDid(), f.RepoName)\n+\tresult, err := us.Tags(f.OwnerDid(), f.RepoName)\n \tif err != nil {\n \t\tlog.Println(\"failed to reach knotserver\", err)\n \t\treturn\n \t}\n \n-\tbody, err = io.ReadAll(resp.Body)\n-\tif err != nil {\n-\t\tlog.Printf(\"error reading response body: %v\", err)\n-\t\treturn\n-\t}\n-\n-\tvar result types.RepoTagsResponse\n-\terr = json.Unmarshal(body, &result)\n-\tif err != nil {\n-\t\tlog.Printf(\"Error unmarshalling response body: %v\", err)\n-\t\treturn\n-\t}\n-\n \ttagMap := make(map[string][]string)\n \tfor _, tag := range result.Tags {\n \t\thash := tag.Hash\n@@ -426,30 +414,47 @@ func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {\n \t\treturn\n \t}\n \n-\tresp, err := us.Tags(f.OwnerDid(), f.RepoName)\n+\tresult, err := us.Tags(f.OwnerDid(), f.RepoName)\n \tif err != nil {\n \t\tlog.Println(\"failed to reach knotserver\", err)\n \t\treturn\n \t}\n \n-\tbody, err := io.ReadAll(resp.Body)\n+\tartifacts, err := db.GetArtifact(s.db, db.NewFilter(\"repo_at\", f.RepoAt))\n \tif err != nil {\n-\t\tlog.Printf(\"Error reading response body: %v\", err)\n+\t\tlog.Println(\"failed grab artifacts\", err)\n \t\treturn\n \t}\n \n-\tvar result types.RepoTagsResponse\n-\terr = json.Unmarshal(body, &result)\n-\tif err != nil {\n-\t\tlog.Println(\"failed to parse response:\", err)\n-\t\treturn\n+\t// convert artifacts to map for easy UI building\n+\tartifactMap := make(map[plumbing.Hash][]db.Artifact)\n+\tfor _, a := range artifacts {\n+\t\tartifactMap[a.Tag] = append(artifactMap[a.Tag], a)\n+\t}\n+\n+\tvar danglingArtifacts []db.Artifact\n+\tfor _, a := range artifacts {\n+\t\tfound := false\n+\t\tfor _, t := range result.Tags {\n+\t\t\tif t.Tag != nil {\n+\t\t\t\tif t.Tag.Hash == a.Tag {\n+\t\t\t\t\tfound = true\n+\t\t\t\t}\n+\t\t\t}\n+\t\t}\n+\n+\t\tif !found {\n+\t\t\tdanglingArtifacts = append(danglingArtifacts, a)\n+\t\t}\n \t}\n \n \tuser := s.auth.GetUser(r)\n \ts.pages.RepoTags(w, pages.RepoTagsParams{\n-\t\tLoggedInUser:     user,\n-\t\tRepoInfo:         f.RepoInfo(s, user),\n-\t\tRepoTagsResponse: result,\n+\t\tLoggedInUser:      user,\n+\t\tRepoInfo:          f.RepoInfo(s, user),\n+\t\tRepoTagsResponse:  *result,\n+\t\tArtifactMap:       artifactMap,\n+\t\tDanglingArtifacts: danglingArtifacts,\n \t})\n \treturn\n }\ndiff --git a/appview/state/router.go b/appview/state/router.go\nindex 8bb0ab3..dcd5fce 100644\n--- a/appview/state/router.go\n+++ b/appview/state/router.go\n@@ -63,7 +63,24 @@ func (s *State) UserRouter() http.Handler {\n \t\t\t})\n \t\t\tr.Get(\"/commit/{ref}\", s.RepoCommit)\n \t\t\tr.Get(\"/branches\", s.RepoBranches)\n-\t\t\tr.Get(\"/tags\", s.RepoTags)\n+\t\t\tr.Route(\"/tags\", func(r chi.Router) {\n+\t\t\t\tr.Get(\"/\", s.RepoTags)\n+\t\t\t\tr.Route(\"/{tag}\", func(r chi.Router) {\n+\t\t\t\t\tr.Use(middleware.AuthMiddleware(s.auth))\n+\t\t\t\t\t// require auth to download for now\n+\t\t\t\t\tr.Get(\"/download/{file}\", s.DownloadArtifact)\n+\n+\t\t\t\t\t// require repo:push to upload or delete artifacts\n+\t\t\t\t\t//\n+\t\t\t\t\t// additionally: only the uploader can truly delete an artifact\n+\t\t\t\t\t// (record+blob will live on their pds)\n+\t\t\t\t\tr.Group(func(r chi.Router) {\n+\t\t\t\t\t\tr.With(RepoPermissionMiddleware(s, \"repo:push\"))\n+\t\t\t\t\t\tr.Post(\"/upload\", s.AttachArtifact)\n+\t\t\t\t\t\tr.Delete(\"/{file}\", s.DeleteArtifact)\n+\t\t\t\t\t})\n+\t\t\t\t})\n+\t\t\t})\n \t\t\tr.Get(\"/blob/{ref}/*\", s.RepoBlob)\n \t\t\tr.Get(\"/raw/{ref}/*\", s.RepoBlobRaw)\n \ndiff --git a/appview/state/signer.go b/appview/state/signer.go\nindex 68abb85..d3936a2 100644\n--- a/appview/state/signer.go\n+++ b/appview/state/signer.go\n@@ -350,7 +350,7 @@ func (us *UnsignedClient) Branches(ownerDid, repoName string) (*http.Response, e\n \treturn us.client.Do(req)\n }\n \n-func (us *UnsignedClient) Tags(ownerDid, repoName string) (*http.Response, error) {\n+func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) {\n \tconst (\n \t\tMethod = \"GET\"\n \t)\n@@ -362,7 +362,23 @@ func (us *UnsignedClient) Tags(ownerDid, repoName string) (*http.Response, error\n \t\treturn nil, err\n \t}\n \n-\treturn us.client.Do(req)\n+\tresp, err := us.client.Do(req)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\n+\tbody, err := io.ReadAll(resp.Body)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\n+\tvar result types.RepoTagsResponse\n+\terr = json.Unmarshal(body, &result)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\n+\treturn &result, nil\n }\n \n func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*http.Response, error) {\ndiff --git a/appview/state/star.go b/appview/state/star.go\nindex 324fb07..b2b2d59 100644\n--- a/appview/state/star.go\n+++ b/appview/state/star.go\n@@ -8,7 +8,7 @@ import (\n \tcomatproto \"github.com/bluesky-social/indigo/api/atproto\"\n \t\"github.com/bluesky-social/indigo/atproto/syntax\"\n \tlexutil \"github.com/bluesky-social/indigo/lex/util\"\n-\ttangled \"tangled.sh/tangled.sh/core/api/tangled\"\n+\t\"tangled.sh/tangled.sh/core/api/tangled\"\n \t\"tangled.sh/tangled.sh/core/appview\"\n \t\"tangled.sh/tangled.sh/core/appview/db\"\n \t\"tangled.sh/tangled.sh/core/appview/pages\"\ndiff --git a/appview/state/state.go b/appview/state/state.go\nindex 89ba080..b676bd9 100644\n--- a/appview/state/state.go\n+++ b/appview/state/state.go\n@@ -17,7 +17,7 @@ import (\n \tlexutil \"github.com/bluesky-social/indigo/lex/util\"\n \tsecurejoin \"github.com/cyphar/filepath-securejoin\"\n \t\"github.com/go-chi/chi/v5\"\n-\ttangled \"tangled.sh/tangled.sh/core/api/tangled\"\n+\t\"tangled.sh/tangled.sh/core/api/tangled\"\n \t\"tangled.sh/tangled.sh/core/appview\"\n \t\"tangled.sh/tangled.sh/core/appview/auth\"\n \t\"tangled.sh/tangled.sh/core/appview/db\"\ndiff --git a/cmd/gen.go b/cmd/gen.go\nindex f1470f0..9d9f421 100644\n--- a/cmd/gen.go\n+++ b/cmd/gen.go\n@@ -2,7 +2,7 @@ package main\n \n import (\n \tcbg \"github.com/whyrusleeping/cbor-gen\"\n-\tshtangled \"tangled.sh/tangled.sh/core/api/tangled\"\n+\t\"tangled.sh/tangled.sh/core/api/tangled\"\n )\n \n func main() {\n@@ -14,18 +14,19 @@ func main() {\n \tif err := genCfg.WriteMapEncodersToFile(\n \t\t\"api/tangled/cbor_gen.go\",\n \t\t\"tangled\",\n-\t\tshtangled.FeedStar{},\n-\t\tshtangled.GraphFollow{},\n-\t\tshtangled.KnotMember{},\n-\t\tshtangled.PublicKey{},\n-\t\tshtangled.RepoIssueComment{},\n-\t\tshtangled.RepoIssueState{},\n-\t\tshtangled.RepoIssue{},\n-\t\tshtangled.Repo{},\n-\t\tshtangled.RepoPull{},\n-\t\tshtangled.RepoPull_Source{},\n-\t\tshtangled.RepoPullStatus{},\n-\t\tshtangled.RepoPullComment{},\n+\t\ttangled.FeedStar{},\n+\t\ttangled.GraphFollow{},\n+\t\ttangled.KnotMember{},\n+\t\ttangled.PublicKey{},\n+\t\ttangled.RepoIssueComment{},\n+\t\ttangled.RepoIssueState{},\n+\t\ttangled.RepoIssue{},\n+\t\ttangled.Repo{},\n+\t\ttangled.RepoPull{},\n+\t\ttangled.RepoPull_Source{},\n+\t\ttangled.RepoPullStatus{},\n+\t\ttangled.RepoPullComment{},\n+\t\ttangled.RepoArtifact{},\n \t); err != nil {\n \t\tpanic(err)\n \t}\ndiff --git a/flake.nix b/flake.nix\nindex b8dfd0e..7b23035 100644\n--- a/flake.nix\n+++ b/flake.nix\n@@ -446,3 +446,4 @@\n     };\n   };\n }\n+\ndiff --git a/go.mod b/go.mod\nindex 76b7143..8b7dc42 100644\n--- a/go.mod\n+++ b/go.mod\n@@ -19,7 +19,7 @@ require (\n \tgithub.com/go-git/go-git/v5 v5.14.0\n \tgithub.com/google/uuid v1.6.0\n \tgithub.com/gorilla/sessions v1.4.0\n-\tgithub.com/ipfs/go-cid v0.4.1\n+\tgithub.com/ipfs/go-cid v0.5.0\n \tgithub.com/mattn/go-sqlite3 v1.14.24\n \tgithub.com/microcosm-cc/bluemonday v1.0.27\n \tgithub.com/resend/resend-go/v2 v2.15.0\ndiff --git a/go.sum b/go.sum\nindex 7ac12ce..1f03761 100644\n--- a/go.sum\n+++ b/go.sum\n@@ -132,6 +132,8 @@ github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNi\n github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=\n github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=\n github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=\n+github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=\n+github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=\n github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=\n github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8=\n github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk=\ndiff --git a/lexicons/artifact.json b/lexicons/artifact.json\nnew file mode 100644\nindex 0000000..83cff30\n--- /dev/null\n+++ b/lexicons/artifact.json\n@@ -0,0 +1,52 @@\n+{\n+  \"lexicon\": 1,\n+  \"id\": \"sh.tangled.repo.artifact\",\n+  \"needsCbor\": true,\n+  \"needsType\": true,\n+  \"defs\": {\n+    \"main\": {\n+      \"type\": \"record\",\n+      \"key\": \"tid\",\n+      \"record\": {\n+        \"type\": \"object\",\n+        \"required\": [\n+          \"name\",\n+          \"repo\",\n+          \"tag\",\n+          \"createdAt\",\n+          \"artifact\"\n+        ],\n+        \"properties\": {\n+          \"name\": {\n+            \"type\": \"string\",\n+            \"description\": \"name of the artifact\"\n+          },\n+          \"repo\": {\n+            \"type\": \"string\",\n+            \"format\": \"at-uri\",\n+            \"description\": \"repo that this artifact is being uploaded to\"\n+          },\n+          \"tag\": {\n+            \"type\": \"bytes\",\n+            \"description\": \"hash of the tag object that this artifact is attached to (only annotated tags are supported)\",\n+            \"minLength\": 20,\n+            \"maxLength\": 20\n+          },\n+          \"createdAt\": {\n+            \"type\": \"string\",\n+            \"format\": \"datetime\",\n+            \"description\": \"time of creation of this artifact\"\n+          },\n+          \"artifact\": {\n+            \"type\": \"blob\",\n+            \"description\": \"the artifact\",\n+            \"accept\": [\n+              \"*/*\"\n+            ],\n+            \"maxSize\": 1000000\n+          }\n+        }\n+      }\n+    }\n+  }\n+}\n-- \n2.43.0\n\n\nFrom b102603e9577696e503347daead1579751e90eea Mon Sep 17 00:00:00 2001\nFrom: Akshay <nerdy@peppe.rs>\nDate: Fri, 2 May 2025 17:36:10 +0100\nSubject: [PATCH 2/2] appview: ingester: process sh.tangled.repo.artifact\n records\n\n---\n appview/db/artifact.go                        | 18 ++---\n appview/ingester.go                           | 57 ++++++++++++++-\n appview/pages/pages.go                        |  2 +-\n .../templates/repo/fragments/artifact.html    |  8 +--\n appview/state/artifact.go                     | 28 ++++----\n appview/state/jetstream.go                    | 70 -------------------\n appview/state/repo.go                         |  2 +-\n appview/state/state.go                        |  2 +-\n flake.nix                                     |  2 +-\n 9 files changed, 87 insertions(+), 102 deletions(-)\n delete mode 100644 appview/state/jetstream.go\n\ndiff --git a/appview/db/artifact.go b/appview/db/artifact.go\nindex df77a55..8e93844 100644\n--- a/appview/db/artifact.go\n+++ b/appview/db/artifact.go\n@@ -23,7 +23,7 @@ type Artifact struct {\n \tBlobCid  cid.Cid\n \tName     string\n \tSize     uint64\n-\tMimetype string\n+\tMimeType string\n }\n \n func (a *Artifact) ArtifactAt() syntax.ATURI {\n@@ -52,28 +52,28 @@ func AddArtifact(e Execer, artifact Artifact) error {\n \t\tartifact.BlobCid.String(),\n \t\tartifact.Name,\n \t\tartifact.Size,\n-\t\tartifact.Mimetype,\n+\t\tartifact.MimeType,\n \t)\n \treturn err\n }\n \n-type Filter struct {\n+type filter struct {\n \tkey string\n \targ any\n }\n \n-func NewFilter(key string, arg any) Filter {\n-\treturn Filter{\n+func Filter(key string, arg any) filter {\n+\treturn filter{\n \t\tkey: key,\n \t\targ: arg,\n \t}\n }\n \n-func (f Filter) Condition() string {\n+func (f filter) Condition() string {\n \treturn fmt.Sprintf(\"%s = ?\", f.key)\n }\n \n-func GetArtifact(e Execer, filters ...Filter) ([]Artifact, error) {\n+func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) {\n \tvar artifacts []Artifact\n \n \tvar conditions []string\n@@ -124,7 +124,7 @@ func GetArtifact(e Execer, filters ...Filter) ([]Artifact, error) {\n \t\t\t&blobCid,\n \t\t\t&artifact.Name,\n \t\t\t&artifact.Size,\n-\t\t\t&artifact.Mimetype,\n+\t\t\t&artifact.MimeType,\n \t\t); err != nil {\n \t\t\treturn nil, err\n \t\t}\n@@ -146,7 +146,7 @@ func GetArtifact(e Execer, filters ...Filter) ([]Artifact, error) {\n \treturn artifacts, nil\n }\n \n-func RemoveArtifact(e Execer, filters ...Filter) error {\n+func DeleteArtifact(e Execer, filters ...filter) error {\n \tvar conditions []string\n \tvar args []any\n \tfor _, filter := range filters {\ndiff --git a/appview/ingester.go b/appview/ingester.go\nindex 75b7121..8079eb3 100644\n--- a/appview/ingester.go\n+++ b/appview/ingester.go\n@@ -5,10 +5,13 @@ import (\n \t\"encoding/json\"\n \t\"fmt\"\n \t\"log\"\n+\t\"time\"\n \n \t\"github.com/bluesky-social/indigo/atproto/syntax\"\n \t\"github.com/bluesky-social/jetstream/pkg/models\"\n-\ttangled \"tangled.sh/tangled.sh/core/api/tangled\"\n+\t\"github.com/go-git/go-git/v5/plumbing\"\n+\t\"github.com/ipfs/go-cid\"\n+\t\"tangled.sh/tangled.sh/core/api/tangled\"\n \t\"tangled.sh/tangled.sh/core/appview/db\"\n )\n \n@@ -36,6 +39,8 @@ func Ingest(d db.DbWrapper) Ingester {\n \t\t\tingestStar(&d, e)\n \t\tcase tangled.PublicKeyNSID:\n \t\t\tingestPublicKey(&d, e)\n+\t\tcase tangled.RepoArtifactNSID:\n+\t\t\tingestArtifact(&d, e)\n \t\t}\n \n \t\treturn err\n@@ -131,3 +136,53 @@ func ingestPublicKey(d *db.DbWrapper, e *models.Event) error {\n \n \treturn nil\n }\n+\n+func ingestArtifact(d *db.DbWrapper, e *models.Event) error {\n+\tdid := e.Did\n+\tvar err error\n+\n+\tswitch e.Commit.Operation {\n+\tcase models.CommitOperationCreate, models.CommitOperationUpdate:\n+\t\tlog.Println(\"processing add of artifact\")\n+\t\traw := json.RawMessage(e.Commit.Record)\n+\t\trecord := tangled.RepoArtifact{}\n+\t\terr = json.Unmarshal(raw, &record)\n+\t\tif err != nil {\n+\t\t\tlog.Printf(\"invalid record: %s\", err)\n+\t\t\treturn err\n+\t\t}\n+\n+\t\trepoAt, err := syntax.ParseATURI(record.Repo)\n+\t\tif err != nil {\n+\t\t\treturn err\n+\t\t}\n+\n+\t\tcreatedAt, err := time.Parse(time.RFC3339, record.CreatedAt)\n+\t\tif err != nil {\n+\t\t\tcreatedAt = time.Now()\n+\t\t}\n+\n+\t\tartifact := db.Artifact{\n+\t\t\tDid:       did,\n+\t\t\tRkey:      e.Commit.RKey,\n+\t\t\tRepoAt:    repoAt,\n+\t\t\tTag:       plumbing.Hash(record.Tag),\n+\t\t\tCreatedAt: createdAt,\n+\t\t\tBlobCid:   cid.Cid(record.Artifact.Ref),\n+\t\t\tName:      record.Name,\n+\t\t\tSize:      uint64(record.Artifact.Size),\n+\t\t\tMimeType:  record.Artifact.MimeType,\n+\t\t}\n+\n+\t\terr = db.AddArtifact(d, artifact)\n+\tcase models.CommitOperationDelete:\n+\t\tlog.Println(\"processing delete of artifact\")\n+\t\terr = db.DeleteArtifact(d, db.Filter(\"did\", did), db.Filter(\"rkey\", e.Commit.RKey))\n+\t}\n+\n+\tif err != nil {\n+\t\treturn fmt.Errorf(\"failed to %s artifact record: %w\", e.Commit.Operation, err)\n+\t}\n+\n+\treturn nil\n+}\ndiff --git a/appview/pages/pages.go b/appview/pages/pages.go\nindex 40a39a4..38a7128 100644\n--- a/appview/pages/pages.go\n+++ b/appview/pages/pages.go\n@@ -496,7 +496,7 @@ func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {\n \n type RepoArtifactParams struct {\n \tLoggedInUser *auth.User\n-\tRepoInfo     RepoInfo\n+\tRepoInfo     repoinfo.RepoInfo\n \tArtifact     db.Artifact\n }\n \ndiff --git a/appview/pages/templates/repo/fragments/artifact.html b/appview/pages/templates/repo/fragments/artifact.html\nindex 3e0a4ba..bf8849d 100644\n--- a/appview/pages/templates/repo/fragments/artifact.html\n+++ b/appview/pages/templates/repo/fragments/artifact.html\n@@ -6,17 +6,17 @@\n         <a href=\"/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}\" class=\"no-underline hover:no-underline\">\n           {{ .Artifact.Name }}\n         </a>\n-        <span class=\"text-gray-500 dark:text-gray-400 pl-2\">{{ byteFmt .Artifact.Size }}</span>\n+        <span class=\"text-gray-500 dark:text-gray-400 pl-2 text-sm\">{{ byteFmt .Artifact.Size }}</span>\n       </div>\n \n-    <div id=\"right-side\" class=\"text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2\">\n+    <div id=\"right-side\" class=\"text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm\">\n       <span title=\"{{ longTimeFmt .Artifact.CreatedAt }}\" class=\"hidden md:inline\">{{ timeFmt .Artifact.CreatedAt }}</span>\n       <span title=\"{{ longTimeFmt .Artifact.CreatedAt }}\" class=\"       md:hidden\">{{ shortTimeFmt .Artifact.CreatedAt }}</span>\n \n       <span class=\"select-none after:content-['·'] hidden md:inline\"></span>\n-      <span class=\"truncate max-w-[100px] hidden md:inline\">{{ .Artifact.Mimetype }}</span>\n+      <span class=\"truncate max-w-[100px] hidden md:inline\">{{ .Artifact.MimeType }}</span>\n \n-      {{ if and (.LoggedInUser) (eq .LoggedInUser.Did .Artifact.Did) }}\n+      {{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }}\n         <button\n           id=\"delete-{{ $unique }}\"\n           class=\"btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2\"\ndiff --git a/appview/state/artifact.go b/appview/state/artifact.go\nindex 6caca70..2942ba6 100644\n--- a/appview/state/artifact.go\n+++ b/appview/state/artifact.go\n@@ -23,7 +23,7 @@ import (\n func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) {\n \tuser := s.auth.GetUser(r)\n \ttagParam := chi.URLParam(r, \"tag\")\n-\tf, err := fullyResolvedRepo(r)\n+\tf, err := s.fullyResolvedRepo(r)\n \tif err != nil {\n \t\tlog.Println(\"failed to get repo and knot\", err)\n \t\ts.pages.Notice(w, \"upload\", \"failed to upload artifact, error in repo resolution\")\n@@ -98,7 +98,7 @@ func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) {\n \t\tBlobCid:   cid.Cid(uploadBlobResp.Blob.Ref),\n \t\tName:      handler.Filename,\n \t\tSize:      uint64(uploadBlobResp.Blob.Size),\n-\t\tMimetype:  uploadBlobResp.Blob.MimeType,\n+\t\tMimeType:  uploadBlobResp.Blob.MimeType,\n \t}\n \n \terr = db.AddArtifact(tx, artifact)\n@@ -126,7 +126,7 @@ func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) {\n func (s *State) DownloadArtifact(w http.ResponseWriter, r *http.Request) {\n \ttagParam := chi.URLParam(r, \"tag\")\n \tfilename := chi.URLParam(r, \"file\")\n-\tf, err := fullyResolvedRepo(r)\n+\tf, err := s.fullyResolvedRepo(r)\n \tif err != nil {\n \t\tlog.Println(\"failed to get repo and knot\", err)\n \t\treturn\n@@ -143,9 +143,9 @@ func (s *State) DownloadArtifact(w http.ResponseWriter, r *http.Request) {\n \n \tartifacts, err := db.GetArtifact(\n \t\ts.db,\n-\t\tdb.NewFilter(\"repo_at\", f.RepoAt),\n-\t\tdb.NewFilter(\"tag\", tag.Tag.Hash[:]),\n-\t\tdb.NewFilter(\"name\", filename),\n+\t\tdb.Filter(\"repo_at\", f.RepoAt),\n+\t\tdb.Filter(\"tag\", tag.Tag.Hash[:]),\n+\t\tdb.Filter(\"name\", filename),\n \t)\n \tif err != nil {\n \t\tlog.Println(\"failed to get artifacts\", err)\n@@ -173,7 +173,7 @@ func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) {\n \tuser := s.auth.GetUser(r)\n \ttagParam := chi.URLParam(r, \"tag\")\n \tfilename := chi.URLParam(r, \"file\")\n-\tf, err := fullyResolvedRepo(r)\n+\tf, err := s.fullyResolvedRepo(r)\n \tif err != nil {\n \t\tlog.Println(\"failed to get repo and knot\", err)\n \t\treturn\n@@ -185,9 +185,9 @@ func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) {\n \n \tartifacts, err := db.GetArtifact(\n \t\ts.db,\n-\t\tdb.NewFilter(\"repo_at\", f.RepoAt),\n-\t\tdb.NewFilter(\"tag\", tag[:]),\n-\t\tdb.NewFilter(\"name\", filename),\n+\t\tdb.Filter(\"repo_at\", f.RepoAt),\n+\t\tdb.Filter(\"tag\", tag[:]),\n+\t\tdb.Filter(\"name\", filename),\n \t)\n \tif err != nil {\n \t\tlog.Println(\"failed to get artifacts\", err)\n@@ -226,10 +226,10 @@ func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) {\n \t}\n \tdefer tx.Rollback()\n \n-\terr = db.RemoveArtifact(tx,\n-\t\tdb.NewFilter(\"repo_at\", f.RepoAt),\n-\t\tdb.NewFilter(\"tag\", artifact.Tag[:]),\n-\t\tdb.NewFilter(\"name\", filename),\n+\terr = db.DeleteArtifact(tx,\n+\t\tdb.Filter(\"repo_at\", f.RepoAt),\n+\t\tdb.Filter(\"tag\", artifact.Tag[:]),\n+\t\tdb.Filter(\"name\", filename),\n \t)\n \tif err != nil {\n \t\tlog.Println(\"failed to remove artifact record from db\", err)\ndiff --git a/appview/state/jetstream.go b/appview/state/jetstream.go\ndeleted file mode 100644\nindex 99bc009..0000000\n--- a/appview/state/jetstream.go\n+++ /dev/null\n@@ -1,70 +0,0 @@\n-package state\n-\n-import (\n-\t\"context\"\n-\t\"encoding/json\"\n-\t\"fmt\"\n-\t\"log\"\n-\n-\t\"github.com/bluesky-social/indigo/atproto/syntax\"\n-\t\"github.com/bluesky-social/jetstream/pkg/models\"\n-\t\"tangled.sh/tangled.sh/core/api/tangled\"\n-\t\"tangled.sh/tangled.sh/core/appview/db\"\n-)\n-\n-type Ingester func(ctx context.Context, e *models.Event) error\n-\n-func jetstreamIngester(d db.DbWrapper) Ingester {\n-\treturn func(ctx context.Context, e *models.Event) error {\n-\t\tvar err error\n-\t\tdefer func() {\n-\t\t\teventTime := e.TimeUS\n-\t\t\tlastTimeUs := eventTime + 1\n-\t\t\tif err := d.SaveLastTimeUs(lastTimeUs); err != nil {\n-\t\t\t\terr = fmt.Errorf(\"(deferred) failed to save last time us: %w\", err)\n-\t\t\t}\n-\t\t}()\n-\n-\t\tif e.Kind != models.EventKindCommit {\n-\t\t\treturn nil\n-\t\t}\n-\n-\t\tdid := e.Did\n-\t\traw := json.RawMessage(e.Commit.Record)\n-\n-\t\tswitch e.Commit.Collection {\n-\t\tcase tangled.GraphFollowNSID:\n-\t\t\trecord := tangled.GraphFollow{}\n-\t\t\terr := json.Unmarshal(raw, &record)\n-\t\t\tif err != nil {\n-\t\t\t\tlog.Println(\"invalid record\")\n-\t\t\t\treturn err\n-\t\t\t}\n-\t\t\terr = db.AddFollow(d, did, record.Subject, e.Commit.RKey)\n-\t\t\tif err != nil {\n-\t\t\t\treturn fmt.Errorf(\"failed to add follow to db: %w\", err)\n-\t\t\t}\n-\t\tcase tangled.FeedStarNSID:\n-\t\t\trecord := tangled.FeedStar{}\n-\t\t\terr := json.Unmarshal(raw, &record)\n-\t\t\tif err != nil {\n-\t\t\t\tlog.Println(\"invalid record\")\n-\t\t\t\treturn err\n-\t\t\t}\n-\n-\t\t\tsubjectUri, err := syntax.ParseATURI(record.Subject)\n-\n-\t\t\tif err != nil {\n-\t\t\t\tlog.Println(\"invalid record\")\n-\t\t\t\treturn err\n-\t\t\t}\n-\n-\t\t\terr = db.AddStar(d, did, subjectUri, e.Commit.RKey)\n-\t\t\tif err != nil {\n-\t\t\t\treturn fmt.Errorf(\"failed to add follow to db: %w\", err)\n-\t\t\t}\n-\t\t}\n-\n-\t\treturn err\n-\t}\n-}\ndiff --git a/appview/state/repo.go b/appview/state/repo.go\nindex 93547e4..f0c69c4 100644\n--- a/appview/state/repo.go\n+++ b/appview/state/repo.go\n@@ -420,7 +420,7 @@ func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {\n \t\treturn\n \t}\n \n-\tartifacts, err := db.GetArtifact(s.db, db.NewFilter(\"repo_at\", f.RepoAt))\n+\tartifacts, err := db.GetArtifact(s.db, db.Filter(\"repo_at\", f.RepoAt))\n \tif err != nil {\n \t\tlog.Println(\"failed grab artifacts\", err)\n \t\treturn\ndiff --git a/appview/state/state.go b/appview/state/state.go\nindex b676bd9..1628458 100644\n--- a/appview/state/state.go\n+++ b/appview/state/state.go\n@@ -63,7 +63,7 @@ func Make(config *appview.Config) (*State, error) {\n \tjc, err := jetstream.NewJetstreamClient(\n \t\tconfig.JetstreamEndpoint,\n \t\t\"appview\",\n-\t\t[]string{tangled.GraphFollowNSID, tangled.FeedStarNSID, tangled.PublicKeyNSID},\n+\t\t[]string{tangled.GraphFollowNSID, tangled.FeedStarNSID, tangled.PublicKeyNSID, tangled.RepoArtifactNSID},\n \t\tnil,\n \t\tslog.Default(),\n \t\twrapper,\ndiff --git a/flake.nix b/flake.nix\nindex 7b23035..8e88d55 100644\n--- a/flake.nix\n+++ b/flake.nix\n@@ -49,7 +49,7 @@\n     inherit (gitignore.lib) gitignoreSource;\n   in {\n     overlays.default = final: prev: let\n-      goModHash = \"sha256-EilWxfqrcKDaSR5zA3ZuDSCq7V+/IfWpKPu8HWhpndA=\";\n+      goModHash = \"sha256-CmBuvv3duQQoc8iTW4244w1rYLGeqMQS+qQ3wwReZZg=\";\n       buildCmdPackage = name:\n         final.buildGoModule {\n           pname = name;\n-- \n2.43.0\n\n",
    "title": "appview: introduce release artifacts",
    "pullId": 74,
    "source": {
      "branch": "release-artifacts"
    },
    "createdAt": "",
    "targetRepo": "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.repo/3liuighjy2h22",
    "targetBranch": "master"
  }
}