Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

appview: introduce release artifacts

authored by oppi.li and committed by

Tangled 7d98bb05 de6ce24d

+825 -56
api/tangled/cbor_gen.go

This is a binary file and will not be displayed.

api/tangled/repoartifact.go

This is a binary file and will not be displayed.

+166
appview/db/artifact.go
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "github.com/go-git/go-git/v5/plumbing" 10 + "github.com/ipfs/go-cid" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + ) 13 + 14 + type Artifact struct { 15 + Id uint64 16 + Did string 17 + Rkey string 18 + 19 + RepoAt syntax.ATURI 20 + Tag plumbing.Hash 21 + CreatedAt time.Time 22 + 23 + BlobCid cid.Cid 24 + Name string 25 + Size uint64 26 + Mimetype string 27 + } 28 + 29 + func (a *Artifact) ArtifactAt() syntax.ATURI { 30 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoPullNSID, a.Rkey)) 31 + } 32 + 33 + func AddArtifact(e Execer, artifact Artifact) error { 34 + _, err := e.Exec( 35 + `insert or ignore into artifacts ( 36 + did, 37 + rkey, 38 + repo_at, 39 + tag, 40 + created, 41 + blob_cid, 42 + name, 43 + size, 44 + mimetype 45 + ) 46 + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 47 + artifact.Did, 48 + artifact.Rkey, 49 + artifact.RepoAt, 50 + artifact.Tag[:], 51 + artifact.CreatedAt.Format(time.RFC3339), 52 + artifact.BlobCid.String(), 53 + artifact.Name, 54 + artifact.Size, 55 + artifact.Mimetype, 56 + ) 57 + return err 58 + } 59 + 60 + type Filter struct { 61 + key string 62 + arg any 63 + } 64 + 65 + func NewFilter(key string, arg any) Filter { 66 + return Filter{ 67 + key: key, 68 + arg: arg, 69 + } 70 + } 71 + 72 + func (f Filter) Condition() string { 73 + return fmt.Sprintf("%s = ?", f.key) 74 + } 75 + 76 + func GetArtifact(e Execer, filters ...Filter) ([]Artifact, error) { 77 + var artifacts []Artifact 78 + 79 + var conditions []string 80 + var args []any 81 + for _, filter := range filters { 82 + conditions = append(conditions, filter.Condition()) 83 + args = append(args, filter.arg) 84 + } 85 + 86 + whereClause := "" 87 + if conditions != nil { 88 + whereClause = " where " + strings.Join(conditions, " and ") 89 + } 90 + 91 + query := fmt.Sprintf(`select 92 + did, 93 + rkey, 94 + repo_at, 95 + tag, 96 + created, 97 + blob_cid, 98 + name, 99 + size, 100 + mimetype 101 + from artifacts %s`, 102 + whereClause, 103 + ) 104 + 105 + rows, err := e.Query(query, args...) 106 + 107 + if err != nil { 108 + return nil, err 109 + } 110 + defer rows.Close() 111 + 112 + for rows.Next() { 113 + var artifact Artifact 114 + var createdAt string 115 + var tag []byte 116 + var blobCid string 117 + 118 + if err := rows.Scan( 119 + &artifact.Did, 120 + &artifact.Rkey, 121 + &artifact.RepoAt, 122 + &tag, 123 + &createdAt, 124 + &blobCid, 125 + &artifact.Name, 126 + &artifact.Size, 127 + &artifact.Mimetype, 128 + ); err != nil { 129 + return nil, err 130 + } 131 + 132 + artifact.CreatedAt, err = time.Parse(time.RFC3339, createdAt) 133 + if err != nil { 134 + artifact.CreatedAt = time.Now() 135 + } 136 + artifact.Tag = plumbing.Hash(tag) 137 + artifact.BlobCid = cid.MustParse(blobCid) 138 + 139 + artifacts = append(artifacts, artifact) 140 + } 141 + 142 + if err := rows.Err(); err != nil { 143 + return nil, err 144 + } 145 + 146 + return artifacts, nil 147 + } 148 + 149 + func RemoveArtifact(e Execer, filters ...Filter) error { 150 + var conditions []string 151 + var args []any 152 + for _, filter := range filters { 153 + conditions = append(conditions, filter.Condition()) 154 + args = append(args, filter.arg) 155 + } 156 + 157 + whereClause := "" 158 + if conditions != nil { 159 + whereClause = " where " + strings.Join(conditions, " and ") 160 + } 161 + 162 + query := fmt.Sprintf(`delete from artifacts %s`, whereClause) 163 + 164 + _, err := e.Exec(query, args...) 165 + return err 166 + }
+23
appview/db/db.go
··· 208 208 unique(did, email) 209 209 ); 210 210 211 + create table if not exists artifacts ( 212 + -- id 213 + id integer primary key autoincrement, 214 + did text not null, 215 + rkey text not null, 216 + 217 + -- meta 218 + repo_at text not null, 219 + tag binary(20) not null, 220 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 221 + 222 + -- data 223 + blob_cid text not null, 224 + name text not null, 225 + size integer not null default 0, 226 + mimetype string not null default "*/*", 227 + 228 + -- constraints 229 + unique(did, rkey), -- record must be unique 230 + unique(repo_at, tag, name), -- for a given tag object, each file must be unique 231 + foreign key (repo_at) references repos(at_uri) on delete cascade 232 + ); 233 + 211 234 create table if not exists migrations ( 212 235 id integer primary key autoincrement, 213 236 name text unique
+1 -1
appview/db/pulls.go
··· 10 10 11 11 "github.com/bluekeyes/go-gitdiff/gitdiff" 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - tangled "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 14 "tangled.sh/tangled.sh/core/patchutil" 15 15 "tangled.sh/tangled.sh/core/types" 16 16 )
+13
appview/pages/pages.go
··· 28 28 "github.com/alecthomas/chroma/v2/lexers" 29 29 "github.com/alecthomas/chroma/v2/styles" 30 30 "github.com/bluesky-social/indigo/atproto/syntax" 31 + "github.com/go-git/go-git/v5/plumbing" 31 32 "github.com/go-git/go-git/v5/plumbing/object" 32 33 "github.com/microcosm-cc/bluemonday" 33 34 ) ··· 485 484 RepoInfo repoinfo.RepoInfo 486 485 Active string 487 486 types.RepoTagsResponse 487 + ArtifactMap map[plumbing.Hash][]db.Artifact 488 + DanglingArtifacts []db.Artifact 488 489 } 489 490 490 491 func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 491 492 params.Active = "overview" 492 493 return p.executeRepo("repo/tags", w, params) 494 + } 495 + 496 + type RepoArtifactParams struct { 497 + LoggedInUser *auth.User 498 + RepoInfo RepoInfo 499 + Artifact db.Artifact 500 + } 501 + 502 + func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { 503 + return p.executePlain("repo/fragments/artifact", w, params) 493 504 } 494 505 495 506 type RepoBlobParams struct {
+34
appview/pages/templates/repo/fragments/artifact.html
··· 1 + {{ define "repo/fragments/artifact" }} 2 + {{ $unique := .Artifact.BlobCid.String }} 3 + <div id="artifact-{{ $unique }}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 4 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 5 + {{ i "box" "w-4 h-4" }} 6 + <a href="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}" class="no-underline hover:no-underline"> 7 + {{ .Artifact.Name }} 8 + </a> 9 + <span class="text-gray-500 dark:text-gray-400 pl-2">{{ byteFmt .Artifact.Size }}</span> 10 + </div> 11 + 12 + <div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2"> 13 + <span title="{{ longTimeFmt .Artifact.CreatedAt }}" class="hidden md:inline">{{ timeFmt .Artifact.CreatedAt }}</span> 14 + <span title="{{ longTimeFmt .Artifact.CreatedAt }}" class=" md:hidden">{{ shortTimeFmt .Artifact.CreatedAt }}</span> 15 + 16 + <span class="select-none after:content-['·'] hidden md:inline"></span> 17 + <span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.Mimetype }}</span> 18 + 19 + {{ if and (.LoggedInUser) (eq .LoggedInUser.Did .Artifact.Did) }} 20 + <button 21 + id="delete-{{ $unique }}" 22 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2" 23 + title="Delete artifact" 24 + hx-delete="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/{{ .Artifact.Name | urlquery }}" 25 + hx-swap="outerHTML" 26 + hx-target="#artifact-{{ $unique }}" 27 + hx-disabled-elt="#delete-{{ $unique }}" 28 + hx-confirm="Are you sure you want to delete the artifact '{{ .Artifact.Name }}'?"> 29 + {{ i "trash-2" "w-4 h-4" }} 30 + </button> 31 + {{ end }} 32 + </div> 33 + </div> 34 + {{ end }}
+93 -4
appview/pages/templates/repo/tags.html
··· 11 11 <!-- Header column (top on mobile, left on md+) --> 12 12 <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"> 13 13 <!-- Mobile layout: horizontal --> 14 - <div class="flex md:hidden flex-col py-2 px-2"> 14 + <div class="flex md:hidden flex-col py-2 px-2 text-xl"> 15 15 <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 16 16 {{ i "tag" "w-4 h-4" }} 17 17 {{ .Name }} ··· 54 54 </div> 55 55 56 56 <!-- Content column (bottom on mobile, right on md+) --> 57 - <div class="md:col-span-9 px-2 py-3 md:py-0 md:pb-6"> 57 + <div class="md:col-span-10 px-2 py-3 md:py-0 md:pb-6"> 58 58 {{ if .Tag }} 59 59 {{ $messageParts := splitN .Tag.Message "\n\n" 2 }} 60 - <p class="font-bold">{{ index $messageParts 0 }}</p> 60 + <p class="font-bold text-lg">{{ index $messageParts 0 }}</p> 61 61 {{ if gt (len $messageParts) 1 }} 62 - <p class="cursor-text pb-2 text-sm">{{ nl2br (index $messageParts 1) }}</p> 62 + <p class="cursor-text py-2">{{ nl2br (index $messageParts 1) }}</p> 63 63 {{ end }} 64 + {{ block "artifacts" (list $ .) }} {{ end }} 64 65 {{ else }} 65 66 <p class="italic text-gray-500 dark:text-gray-400">no message</p> 66 67 {{ end }} ··· 74 73 {{ end }} 75 74 </div> 76 75 </section> 76 + {{ end }} 77 + 78 + {{ define "repoAfter" }} 79 + {{ if gt (len .DanglingArtifacts) 0 }} 80 + <section class="bg-white dark:bg-gray-800 p-6 mt-4"> 81 + {{ block "dangling" . }} {{ end }} 82 + </section> 83 + {{ end }} 84 + {{ end }} 85 + 86 + {{ define "artifacts" }} 87 + {{ $root := index . 0 }} 88 + {{ $tag := index . 1 }} 89 + {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 90 + {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 91 + 92 + {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 93 + <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 94 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 95 + {{ range $artifact := $artifacts }} 96 + {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 97 + {{ template "repo/fragments/artifact" $args }} 98 + {{ end }} 99 + {{ if $isPushAllowed }} 100 + {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 101 + {{ end }} 102 + </div> 103 + {{ end }} 104 + {{ end }} 105 + 106 + {{ define "uploadArtifact" }} 107 + {{ $root := index . 0 }} 108 + {{ $tag := index . 1 }} 109 + {{ $unique := $tag.Tag.Target.String }} 110 + <form 111 + id="upload-{{$unique}}" 112 + method="post" 113 + enctype="multipart/form-data" 114 + hx-post="/{{ $root.RepoInfo.FullName }}/tags/{{ $tag.Name | urlquery }}/upload" 115 + hx-on::after-request="if(event.detail.successful) this.reset()" 116 + hx-disabled-elt="#upload-btn-{{$unique}}" 117 + hx-swap="beforebegin" 118 + hx-target="this" 119 + class="flex items-center gap-2 px-2"> 120 + <div class="flex-grow"> 121 + <input type="file" 122 + name="artifact" 123 + required 124 + class="block py-2 px-0 w-full border-none 125 + text-black dark:text-white 126 + bg-white dark:bg-gray-800 127 + file:mr-4 file:px-2 file:py-2 128 + file:rounded file:border-0 129 + file:text-sm file:font-medium 130 + file:text-gray-700 file:dark:text-gray-300 131 + file:bg-gray-200 file:dark:bg-gray-700 132 + file:hover:bg-gray-100 file:hover:dark:bg-gray-600 133 + "> 134 + </input> 135 + </div> 136 + <div class="flex justify-end"> 137 + <button 138 + type="submit" 139 + class="btn gap-2" 140 + id="upload-btn-{{$unique}}" 141 + title="Upload artifact"> 142 + {{ i "upload" "w-4 h-4" }} 143 + <span class="hidden md:inline">upload</span> 144 + </button> 145 + </div> 146 + </form> 147 + {{ end }} 148 + 149 + {{ define "dangling" }} 150 + {{ $root := . }} 151 + {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 152 + {{ $artifacts := $root.DanglingArtifacts }} 153 + 154 + {{ if and (gt (len $artifacts) 0) $isPushAllowed }} 155 + <h2 class="mb-2 text-sm text-left text-red-700 dark:text-red-400 uppercase font-bold">dangling artifacts</h2> 156 + <p class="mb-4">The tags that these artifacts were attached to have been deleted. These artifacts are only visible to collaborators.</p> 157 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 158 + {{ range $artifact := $artifacts }} 159 + {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 160 + {{ template "repo/fragments/artifact" $args }} 161 + {{ end }} 162 + </div> 163 + {{ end }} 77 164 {{ end }}
+280
appview/state/artifact.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "time" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + "github.com/dustin/go-humanize" 12 + "github.com/go-chi/chi/v5" 13 + "github.com/go-git/go-git/v5/plumbing" 14 + "github.com/ipfs/go-cid" 15 + "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/appview" 17 + "tangled.sh/tangled.sh/core/appview/db" 18 + "tangled.sh/tangled.sh/core/appview/pages" 19 + "tangled.sh/tangled.sh/core/types" 20 + ) 21 + 22 + // TODO: proper statuses here on early exit 23 + func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) { 24 + user := s.auth.GetUser(r) 25 + tagParam := chi.URLParam(r, "tag") 26 + f, err := fullyResolvedRepo(r) 27 + if err != nil { 28 + log.Println("failed to get repo and knot", err) 29 + s.pages.Notice(w, "upload", "failed to upload artifact, error in repo resolution") 30 + return 31 + } 32 + 33 + tag, err := s.resolveTag(f, tagParam) 34 + if err != nil { 35 + log.Println("failed to resolve tag", err) 36 + s.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") 37 + return 38 + } 39 + 40 + file, handler, err := r.FormFile("artifact") 41 + if err != nil { 42 + log.Println("failed to upload artifact", err) 43 + s.pages.Notice(w, "upload", "failed to upload artifact") 44 + return 45 + } 46 + defer file.Close() 47 + 48 + client, _ := s.auth.AuthorizedClient(r) 49 + 50 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 51 + if err != nil { 52 + log.Println("failed to upload blob", err) 53 + s.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") 54 + return 55 + } 56 + 57 + log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 58 + 59 + rkey := appview.TID() 60 + createdAt := time.Now() 61 + 62 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 63 + Collection: tangled.RepoArtifactNSID, 64 + Repo: user.Did, 65 + Rkey: rkey, 66 + Record: &lexutil.LexiconTypeDecoder{ 67 + Val: &tangled.RepoArtifact{ 68 + Artifact: uploadBlobResp.Blob, 69 + CreatedAt: createdAt.Format(time.RFC3339), 70 + Name: handler.Filename, 71 + Repo: f.RepoAt.String(), 72 + Tag: tag.Tag.Hash[:], 73 + }, 74 + }, 75 + }) 76 + if err != nil { 77 + log.Println("failed to create record", err) 78 + s.pages.Notice(w, "upload", "Failed to create artifact record. Try again later.") 79 + return 80 + } 81 + 82 + log.Println(putRecordResp.Uri) 83 + 84 + tx, err := s.db.BeginTx(r.Context(), nil) 85 + if err != nil { 86 + log.Println("failed to start tx") 87 + s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 88 + return 89 + } 90 + defer tx.Rollback() 91 + 92 + artifact := db.Artifact{ 93 + Did: user.Did, 94 + Rkey: rkey, 95 + RepoAt: f.RepoAt, 96 + Tag: tag.Tag.Hash, 97 + CreatedAt: createdAt, 98 + BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), 99 + Name: handler.Filename, 100 + Size: uint64(uploadBlobResp.Blob.Size), 101 + Mimetype: uploadBlobResp.Blob.MimeType, 102 + } 103 + 104 + err = db.AddArtifact(tx, artifact) 105 + if err != nil { 106 + log.Println("failed to add artifact record to db", err) 107 + s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 108 + return 109 + } 110 + 111 + err = tx.Commit() 112 + if err != nil { 113 + log.Println("failed to add artifact record to db") 114 + s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 115 + return 116 + } 117 + 118 + s.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ 119 + LoggedInUser: user, 120 + RepoInfo: f.RepoInfo(s, user), 121 + Artifact: artifact, 122 + }) 123 + } 124 + 125 + // TODO: proper statuses here on early exit 126 + func (s *State) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 127 + tagParam := chi.URLParam(r, "tag") 128 + filename := chi.URLParam(r, "file") 129 + f, err := fullyResolvedRepo(r) 130 + if err != nil { 131 + log.Println("failed to get repo and knot", err) 132 + return 133 + } 134 + 135 + tag, err := s.resolveTag(f, tagParam) 136 + if err != nil { 137 + log.Println("failed to resolve tag", err) 138 + s.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") 139 + return 140 + } 141 + 142 + client, _ := s.auth.AuthorizedClient(r) 143 + 144 + artifacts, err := db.GetArtifact( 145 + s.db, 146 + db.NewFilter("repo_at", f.RepoAt), 147 + db.NewFilter("tag", tag.Tag.Hash[:]), 148 + db.NewFilter("name", filename), 149 + ) 150 + if err != nil { 151 + log.Println("failed to get artifacts", err) 152 + return 153 + } 154 + if len(artifacts) != 1 { 155 + log.Printf("too many or too little artifacts found") 156 + return 157 + } 158 + 159 + artifact := artifacts[0] 160 + 161 + getBlobResp, err := comatproto.SyncGetBlob(r.Context(), client, artifact.BlobCid.String(), artifact.Did) 162 + if err != nil { 163 + log.Println("failed to get blob from pds", err) 164 + return 165 + } 166 + 167 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 168 + w.Write(getBlobResp) 169 + } 170 + 171 + // TODO: proper statuses here on early exit 172 + func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) { 173 + user := s.auth.GetUser(r) 174 + tagParam := chi.URLParam(r, "tag") 175 + filename := chi.URLParam(r, "file") 176 + f, err := fullyResolvedRepo(r) 177 + if err != nil { 178 + log.Println("failed to get repo and knot", err) 179 + return 180 + } 181 + 182 + client, _ := s.auth.AuthorizedClient(r) 183 + 184 + tag := plumbing.NewHash(tagParam) 185 + 186 + artifacts, err := db.GetArtifact( 187 + s.db, 188 + db.NewFilter("repo_at", f.RepoAt), 189 + db.NewFilter("tag", tag[:]), 190 + db.NewFilter("name", filename), 191 + ) 192 + if err != nil { 193 + log.Println("failed to get artifacts", err) 194 + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 195 + return 196 + } 197 + if len(artifacts) != 1 { 198 + s.pages.Notice(w, "remove", "Unable to find artifact.") 199 + return 200 + } 201 + 202 + artifact := artifacts[0] 203 + 204 + if user.Did != artifact.Did { 205 + log.Println("user not authorized to delete artifact", err) 206 + s.pages.Notice(w, "remove", "Unauthorized deletion of artifact.") 207 + return 208 + } 209 + 210 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 211 + Collection: tangled.RepoArtifactNSID, 212 + Repo: user.Did, 213 + Rkey: artifact.Rkey, 214 + }) 215 + if err != nil { 216 + log.Println("failed to get blob from pds", err) 217 + s.pages.Notice(w, "remove", "Failed to remove blob from PDS.") 218 + return 219 + } 220 + 221 + tx, err := s.db.BeginTx(r.Context(), nil) 222 + if err != nil { 223 + log.Println("failed to start tx") 224 + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 225 + return 226 + } 227 + defer tx.Rollback() 228 + 229 + err = db.RemoveArtifact(tx, 230 + db.NewFilter("repo_at", f.RepoAt), 231 + db.NewFilter("tag", artifact.Tag[:]), 232 + db.NewFilter("name", filename), 233 + ) 234 + if err != nil { 235 + log.Println("failed to remove artifact record from db", err) 236 + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 237 + return 238 + } 239 + 240 + err = tx.Commit() 241 + if err != nil { 242 + log.Println("failed to remove artifact record from db") 243 + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 244 + return 245 + } 246 + 247 + w.Write([]byte{}) 248 + } 249 + 250 + func (s *State) resolveTag(f *FullyResolvedRepo, tagParam string) (*types.TagReference, error) { 251 + us, err := NewUnsignedClient(f.Knot, s.config.Dev) 252 + if err != nil { 253 + return nil, err 254 + } 255 + 256 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 257 + if err != nil { 258 + log.Println("failed to reach knotserver", err) 259 + return nil, err 260 + } 261 + 262 + var tag *types.TagReference 263 + for _, t := range result.Tags { 264 + if t.Tag != nil { 265 + if t.Reference.Name == tagParam || t.Reference.Hash == tagParam { 266 + tag = t 267 + } 268 + } 269 + } 270 + 271 + if tag == nil { 272 + return nil, fmt.Errorf("invalid tag, only annotated tags are supported for artifacts") 273 + } 274 + 275 + if tag.Tag.Target.IsZero() { 276 + return nil, fmt.Errorf("invalid tag, only annotated tags are supported for artifacts") 277 + } 278 + 279 + return tag, nil 280 + }
+1 -1
appview/state/follow.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - tangled "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 11 "tangled.sh/tangled.sh/core/appview" 12 12 "tangled.sh/tangled.sh/core/appview/db" 13 13 "tangled.sh/tangled.sh/core/appview/pages"
+70
appview/state/jetstream.go
··· 1 + package state 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/jetstream/pkg/models" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/appview/db" 13 + ) 14 + 15 + type Ingester func(ctx context.Context, e *models.Event) error 16 + 17 + func jetstreamIngester(d db.DbWrapper) Ingester { 18 + return func(ctx context.Context, e *models.Event) error { 19 + var err error 20 + defer func() { 21 + eventTime := e.TimeUS 22 + lastTimeUs := eventTime + 1 23 + if err := d.SaveLastTimeUs(lastTimeUs); err != nil { 24 + err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 25 + } 26 + }() 27 + 28 + if e.Kind != models.EventKindCommit { 29 + return nil 30 + } 31 + 32 + did := e.Did 33 + raw := json.RawMessage(e.Commit.Record) 34 + 35 + switch e.Commit.Collection { 36 + case tangled.GraphFollowNSID: 37 + record := tangled.GraphFollow{} 38 + err := json.Unmarshal(raw, &record) 39 + if err != nil { 40 + log.Println("invalid record") 41 + return err 42 + } 43 + err = db.AddFollow(d, did, record.Subject, e.Commit.RKey) 44 + if err != nil { 45 + return fmt.Errorf("failed to add follow to db: %w", err) 46 + } 47 + case tangled.FeedStarNSID: 48 + record := tangled.FeedStar{} 49 + err := json.Unmarshal(raw, &record) 50 + if err != nil { 51 + log.Println("invalid record") 52 + return err 53 + } 54 + 55 + subjectUri, err := syntax.ParseATURI(record.Subject) 56 + 57 + if err != nil { 58 + log.Println("invalid record") 59 + return err 60 + } 61 + 62 + err = db.AddStar(d, did, subjectUri, e.Commit.RKey) 63 + if err != nil { 64 + return fmt.Errorf("failed to add follow to db: %w", err) 65 + } 66 + } 67 + 68 + return err 69 + } 70 + }
+36 -31
appview/state/repo.go
··· 16 16 "strings" 17 17 "time" 18 18 19 - "github.com/bluesky-social/indigo/atproto/data" 20 - "github.com/bluesky-social/indigo/atproto/identity" 21 - "github.com/bluesky-social/indigo/atproto/syntax" 22 - securejoin "github.com/cyphar/filepath-securejoin" 23 - "github.com/go-chi/chi/v5" 24 - "github.com/go-git/go-git/v5/plumbing" 25 19 "tangled.sh/tangled.sh/core/api/tangled" 26 20 "tangled.sh/tangled.sh/core/appview" 27 21 "tangled.sh/tangled.sh/core/appview/auth" ··· 25 31 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 26 32 "tangled.sh/tangled.sh/core/appview/pagination" 27 33 "tangled.sh/tangled.sh/core/types" 34 + 35 + "github.com/bluesky-social/indigo/atproto/data" 36 + "github.com/bluesky-social/indigo/atproto/identity" 37 + "github.com/bluesky-social/indigo/atproto/syntax" 38 + securejoin "github.com/cyphar/filepath-securejoin" 39 + "github.com/go-chi/chi/v5" 40 + "github.com/go-git/go-git/v5/plumbing" 28 41 29 42 comatproto "github.com/bluesky-social/indigo/api/atproto" 30 43 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 172 171 return 173 172 } 174 173 175 - resp, err = us.Tags(f.OwnerDid(), f.RepoName) 174 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 176 175 if err != nil { 177 176 log.Println("failed to reach knotserver", err) 178 - return 179 - } 180 - 181 - body, err = io.ReadAll(resp.Body) 182 - if err != nil { 183 - log.Printf("error reading response body: %v", err) 184 - return 185 - } 186 - 187 - var result types.RepoTagsResponse 188 - err = json.Unmarshal(body, &result) 189 - if err != nil { 190 - log.Printf("Error unmarshalling response body: %v", err) 191 177 return 192 178 } 193 179 ··· 414 426 return 415 427 } 416 428 417 - resp, err := us.Tags(f.OwnerDid(), f.RepoName) 429 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 418 430 if err != nil { 419 431 log.Println("failed to reach knotserver", err) 420 432 return 421 433 } 422 434 423 - body, err := io.ReadAll(resp.Body) 435 + artifacts, err := db.GetArtifact(s.db, db.NewFilter("repo_at", f.RepoAt)) 424 436 if err != nil { 425 - log.Printf("Error reading response body: %v", err) 437 + log.Println("failed grab artifacts", err) 426 438 return 427 439 } 428 440 429 - var result types.RepoTagsResponse 430 - err = json.Unmarshal(body, &result) 431 - if err != nil { 432 - log.Println("failed to parse response:", err) 433 - return 441 + // convert artifacts to map for easy UI building 442 + artifactMap := make(map[plumbing.Hash][]db.Artifact) 443 + for _, a := range artifacts { 444 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 445 + } 446 + 447 + var danglingArtifacts []db.Artifact 448 + for _, a := range artifacts { 449 + found := false 450 + for _, t := range result.Tags { 451 + if t.Tag != nil { 452 + if t.Tag.Hash == a.Tag { 453 + found = true 454 + } 455 + } 456 + } 457 + 458 + if !found { 459 + danglingArtifacts = append(danglingArtifacts, a) 460 + } 434 461 } 435 462 436 463 user := s.auth.GetUser(r) 437 464 s.pages.RepoTags(w, pages.RepoTagsParams{ 438 - LoggedInUser: user, 439 - RepoInfo: f.RepoInfo(s, user), 440 - RepoTagsResponse: result, 465 + LoggedInUser: user, 466 + RepoInfo: f.RepoInfo(s, user), 467 + RepoTagsResponse: *result, 468 + ArtifactMap: artifactMap, 469 + DanglingArtifacts: danglingArtifacts, 441 470 }) 442 471 return 443 472 }
+18 -1
appview/state/router.go
··· 63 63 }) 64 64 r.Get("/commit/{ref}", s.RepoCommit) 65 65 r.Get("/branches", s.RepoBranches) 66 - r.Get("/tags", s.RepoTags) 66 + r.Route("/tags", func(r chi.Router) { 67 + r.Get("/", s.RepoTags) 68 + r.Route("/{tag}", func(r chi.Router) { 69 + r.Use(middleware.AuthMiddleware(s.auth)) 70 + // require auth to download for now 71 + r.Get("/download/{file}", s.DownloadArtifact) 72 + 73 + // require repo:push to upload or delete artifacts 74 + // 75 + // additionally: only the uploader can truly delete an artifact 76 + // (record+blob will live on their pds) 77 + r.Group(func(r chi.Router) { 78 + r.With(RepoPermissionMiddleware(s, "repo:push")) 79 + r.Post("/upload", s.AttachArtifact) 80 + r.Delete("/{file}", s.DeleteArtifact) 81 + }) 82 + }) 83 + }) 67 84 r.Get("/blob/{ref}/*", s.RepoBlob) 68 85 r.Get("/raw/{ref}/*", s.RepoBlobRaw) 69 86
+18 -2
appview/state/signer.go
··· 350 350 return us.client.Do(req) 351 351 } 352 352 353 - func (us *UnsignedClient) Tags(ownerDid, repoName string) (*http.Response, error) { 353 + func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) { 354 354 const ( 355 355 Method = "GET" 356 356 ) ··· 362 362 return nil, err 363 363 } 364 364 365 - return us.client.Do(req) 365 + resp, err := us.client.Do(req) 366 + if err != nil { 367 + return nil, err 368 + } 369 + 370 + body, err := io.ReadAll(resp.Body) 371 + if err != nil { 372 + return nil, err 373 + } 374 + 375 + var result types.RepoTagsResponse 376 + err = json.Unmarshal(body, &result) 377 + if err != nil { 378 + return nil, err 379 + } 380 + 381 + return &result, nil 366 382 } 367 383 368 384 func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*http.Response, error) {
+1 -1
appview/state/star.go
··· 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 - tangled "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 12 "tangled.sh/tangled.sh/core/appview" 13 13 "tangled.sh/tangled.sh/core/appview/db" 14 14 "tangled.sh/tangled.sh/core/appview/pages"
+1 -1
appview/state/state.go
··· 17 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 18 securejoin "github.com/cyphar/filepath-securejoin" 19 19 "github.com/go-chi/chi/v5" 20 - tangled "tangled.sh/tangled.sh/core/api/tangled" 20 + "tangled.sh/tangled.sh/core/api/tangled" 21 21 "tangled.sh/tangled.sh/core/appview" 22 22 "tangled.sh/tangled.sh/core/appview/auth" 23 23 "tangled.sh/tangled.sh/core/appview/db"
+14 -13
cmd/gen.go
··· 2 2 3 3 import ( 4 4 cbg "github.com/whyrusleeping/cbor-gen" 5 - shtangled "tangled.sh/tangled.sh/core/api/tangled" 5 + "tangled.sh/tangled.sh/core/api/tangled" 6 6 ) 7 7 8 8 func main() { ··· 14 14 if err := genCfg.WriteMapEncodersToFile( 15 15 "api/tangled/cbor_gen.go", 16 16 "tangled", 17 - shtangled.FeedStar{}, 18 - shtangled.GraphFollow{}, 19 - shtangled.KnotMember{}, 20 - shtangled.PublicKey{}, 21 - shtangled.RepoIssueComment{}, 22 - shtangled.RepoIssueState{}, 23 - shtangled.RepoIssue{}, 24 - shtangled.Repo{}, 25 - shtangled.RepoPull{}, 26 - shtangled.RepoPull_Source{}, 27 - shtangled.RepoPullStatus{}, 28 - shtangled.RepoPullComment{}, 17 + tangled.FeedStar{}, 18 + tangled.GraphFollow{}, 19 + tangled.KnotMember{}, 20 + tangled.PublicKey{}, 21 + tangled.RepoIssueComment{}, 22 + tangled.RepoIssueState{}, 23 + tangled.RepoIssue{}, 24 + tangled.Repo{}, 25 + tangled.RepoPull{}, 26 + tangled.RepoPull_Source{}, 27 + tangled.RepoPullStatus{}, 28 + tangled.RepoPullComment{}, 29 + tangled.RepoArtifact{}, 29 30 ); err != nil { 30 31 panic(err) 31 32 }
+1
flake.nix
··· 446 446 }; 447 447 }; 448 448 } 449 +
+1 -1
go.mod
··· 19 19 github.com/go-git/go-git/v5 v5.14.0 20 20 github.com/google/uuid v1.6.0 21 21 github.com/gorilla/sessions v1.4.0 22 - github.com/ipfs/go-cid v0.4.1 22 + github.com/ipfs/go-cid v0.5.0 23 23 github.com/mattn/go-sqlite3 v1.14.24 24 24 github.com/microcosm-cc/bluemonday v1.0.27 25 25 github.com/resend/resend-go/v2 v2.15.0
+2
go.sum
··· 132 132 github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 133 133 github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 134 134 github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 135 + github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 136 + github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 135 137 github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 136 138 github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 137 139 github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk=
+52
lexicons/artifact.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.artifact", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "repo", 15 + "tag", 16 + "createdAt", 17 + "artifact" 18 + ], 19 + "properties": { 20 + "name": { 21 + "type": "string", 22 + "description": "name of the artifact" 23 + }, 24 + "repo": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "repo that this artifact is being uploaded to" 28 + }, 29 + "tag": { 30 + "type": "bytes", 31 + "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 + "minLength": 20, 33 + "maxLength": 20 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "time of creation of this artifact" 39 + }, 40 + "artifact": { 41 + "type": "blob", 42 + "description": "the artifact", 43 + "accept": [ 44 + "*/*" 45 + ], 46 + "maxSize": 1000000 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }