diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -8,6 +8,7 @@ "io" "math" "sort" + util "github.com/bluesky-social/indigo/lex/util" cid "github.com/ipfs/go-cid" cbg "github.com/whyrusleeping/cbor-gen" xerrors "golang.org/x/xerrors" @@ -3098,3 +3099,293 @@ } return nil } +func (t *RepoArtifact) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 6 + + if t.Tag == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Tag (util.LexBytes) (slice) + if t.Tag != nil { + + if len("tag") > 1000000 { + return xerrors.Errorf("Value in field \"tag\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tag"))); err != nil { + return err + } + if _, err := cw.WriteString(string("tag")); err != nil { + return err + } + + if len(t.Tag) > 2097152 { + return xerrors.Errorf("Byte array in field t.Tag was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajByteString, uint64(len(t.Tag))); err != nil { + return err + } + + if _, err := cw.Write(t.Tag); err != nil { + return err + } + + } + + // t.Name (string) (string) + if len("name") > 1000000 { + return xerrors.Errorf("Value in field \"name\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { + return err + } + if _, err := cw.WriteString(string("name")); err != nil { + return err + } + + if len(t.Name) > 1000000 { + return xerrors.Errorf("Value in field t.Name was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Name)); err != nil { + return err + } + + // t.Repo (string) (string) + if len("repo") > 1000000 { + return xerrors.Errorf("Value in field \"repo\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { + return err + } + if _, err := cw.WriteString(string("repo")); err != nil { + return err + } + + if len(t.Repo) > 1000000 { + return xerrors.Errorf("Value in field t.Repo was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Repo)); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.artifact"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sh.tangled.repo.artifact")); err != nil { + return err + } + + // t.Artifact (util.LexBlob) (struct) + if len("artifact") > 1000000 { + return xerrors.Errorf("Value in field \"artifact\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artifact"))); err != nil { + return err + } + if _, err := cw.WriteString(string("artifact")); err != nil { + return err + } + + if err := t.Artifact.MarshalCBOR(cw); err != nil { + return err + } + + // t.CreatedAt (string) (string) + if len("createdAt") > 1000000 { + return xerrors.Errorf("Value in field \"createdAt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("createdAt")); err != nil { + return err + } + + if len(t.CreatedAt) > 1000000 { + return xerrors.Errorf("Value in field t.CreatedAt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { + return err + } + return nil +} + +func (t *RepoArtifact) UnmarshalCBOR(r io.Reader) (err error) { + *t = RepoArtifact{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("RepoArtifact: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 9) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Tag (util.LexBytes) (slice) + case "tag": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 2097152 { + return fmt.Errorf("t.Tag: byte array too large (%d)", extra) + } + if maj != cbg.MajByteString { + return fmt.Errorf("expected byte array") + } + + if extra > 0 { + t.Tag = make([]uint8, extra) + } + + if _, err := io.ReadFull(cr, t.Tag); err != nil { + return err + } + + // t.Name (string) (string) + case "name": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Name = string(sval) + } + // t.Repo (string) (string) + case "repo": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Repo = string(sval) + } + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.Artifact (util.LexBlob) (struct) + case "artifact": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Artifact = new(util.LexBlob) + if err := t.Artifact.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Artifact pointer: %w", err) + } + } + + } + // t.CreatedAt (string) (string) + case "createdAt": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.CreatedAt = string(sval) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} diff --git a/api/tangled/repoartifact.go b/api/tangled/repoartifact.go new file mode 100644 --- /dev/null +++ b/api/tangled/repoartifact.go @@ -0,0 +1,31 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.repo.artifact + +import ( + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + RepoArtifactNSID = "sh.tangled.repo.artifact" +) + +func init() { + util.RegisterType("sh.tangled.repo.artifact", &RepoArtifact{}) +} // +// RECORDTYPE: RepoArtifact +type RepoArtifact struct { + LexiconTypeID string `json:"$type,const=sh.tangled.repo.artifact" cborgen:"$type,const=sh.tangled.repo.artifact"` + // artifact: the artifact + Artifact *util.LexBlob `json:"artifact" cborgen:"artifact"` + // createdAt: time of creation of this artifact + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // name: name of the artifact + Name string `json:"name" cborgen:"name"` + // repo: repo that this artifact is being uploaded to + Repo string `json:"repo" cborgen:"repo"` + // tag: hash of the tag object that this artifact is attached to (only annotated tags are supported) + Tag util.LexBytes `json:"tag,omitempty" cborgen:"tag,omitempty"` +} diff --git a/appview/db/artifact.go b/appview/db/artifact.go new file mode 100644 --- /dev/null +++ b/appview/db/artifact.go @@ -0,0 +1,166 @@ +package db + +import ( + "fmt" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/go-git/go-git/v5/plumbing" + "github.com/ipfs/go-cid" + "tangled.sh/tangled.sh/core/api/tangled" +) + +type Artifact struct { + Id uint64 + Did string + Rkey string + + RepoAt syntax.ATURI + Tag plumbing.Hash + CreatedAt time.Time + + BlobCid cid.Cid + Name string + Size uint64 + Mimetype string +} + +func (a *Artifact) ArtifactAt() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoPullNSID, a.Rkey)) +} + +func AddArtifact(e Execer, artifact Artifact) error { + _, err := e.Exec( + `insert or ignore into artifacts ( + did, + rkey, + repo_at, + tag, + created, + blob_cid, + name, + size, + mimetype + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + artifact.Did, + artifact.Rkey, + artifact.RepoAt, + artifact.Tag[:], + artifact.CreatedAt.Format(time.RFC3339), + artifact.BlobCid.String(), + artifact.Name, + artifact.Size, + artifact.Mimetype, + ) + return err +} + +type Filter struct { + key string + arg any +} + +func NewFilter(key string, arg any) Filter { + return Filter{ + key: key, + arg: arg, + } +} + +func (f Filter) Condition() string { + return fmt.Sprintf("%s = ?", f.key) +} + +func GetArtifact(e Execer, filters ...Filter) ([]Artifact, error) { + var artifacts []Artifact + + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.arg) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf(`select + did, + rkey, + repo_at, + tag, + created, + blob_cid, + name, + size, + mimetype + from artifacts %s`, + whereClause, + ) + + rows, err := e.Query(query, args...) + + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var artifact Artifact + var createdAt string + var tag []byte + var blobCid string + + if err := rows.Scan( + &artifact.Did, + &artifact.Rkey, + &artifact.RepoAt, + &tag, + &createdAt, + &blobCid, + &artifact.Name, + &artifact.Size, + &artifact.Mimetype, + ); err != nil { + return nil, err + } + + artifact.CreatedAt, err = time.Parse(time.RFC3339, createdAt) + if err != nil { + artifact.CreatedAt = time.Now() + } + artifact.Tag = plumbing.Hash(tag) + artifact.BlobCid = cid.MustParse(blobCid) + + artifacts = append(artifacts, artifact) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return artifacts, nil +} + +func RemoveArtifact(e Execer, filters ...Filter) error { + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.arg) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf(`delete from artifacts %s`, whereClause) + + _, err := e.Exec(query, args...) + return err +} diff --git a/appview/db/db.go b/appview/db/db.go --- a/appview/db/db.go +++ b/appview/db/db.go @@ -208,6 +208,29 @@ created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), unique(did, email) ); + create table if not exists artifacts ( + -- id + id integer primary key autoincrement, + did text not null, + rkey text not null, + + -- meta + repo_at text not null, + tag binary(20) not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + -- data + blob_cid text not null, + name text not null, + size integer not null default 0, + mimetype string not null default "*/*", + + -- constraints + unique(did, rkey), -- record must be unique + unique(repo_at, tag, name), -- for a given tag object, each file must be unique + foreign key (repo_at) references repos(at_uri) on delete cascade + ); + create table if not exists migrations ( id integer primary key autoincrement, name text unique diff --git a/appview/db/pulls.go b/appview/db/pulls.go --- a/appview/db/pulls.go +++ b/appview/db/pulls.go @@ -10,7 +10,7 @@ "time" "github.com/bluekeyes/go-gitdiff/gitdiff" "github.com/bluesky-social/indigo/atproto/syntax" - tangled "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/patchutil" "tangled.sh/tangled.sh/core/types" ) diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -28,6 +28,7 @@ chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/microcosm-cc/bluemonday" ) @@ -484,11 +485,23 @@ LoggedInUser *auth.User RepoInfo repoinfo.RepoInfo Active string types.RepoTagsResponse + ArtifactMap map[plumbing.Hash][]db.Artifact + DanglingArtifacts []db.Artifact } func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { params.Active = "overview" return p.executeRepo("repo/tags", w, params) +} + +type RepoArtifactParams struct { + LoggedInUser *auth.User + RepoInfo RepoInfo + Artifact db.Artifact +} + +func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { + return p.executePlain("repo/fragments/artifact", w, params) } type RepoBlobParams struct { diff --git a/appview/pages/templates/repo/fragments/artifact.html b/appview/pages/templates/repo/fragments/artifact.html new file mode 100644 --- /dev/null +++ b/appview/pages/templates/repo/fragments/artifact.html @@ -0,0 +1,34 @@ +{{ define "repo/fragments/artifact" }} +{{ $unique := .Artifact.BlobCid.String }} +
+
+ {{ i "box" "w-4 h-4" }} + + {{ .Artifact.Name }} + + {{ byteFmt .Artifact.Size }} +
+ +
+ + {{ shortTimeFmt .Artifact.CreatedAt }} + + + + + {{ if and (.LoggedInUser) (eq .LoggedInUser.Did .Artifact.Did) }} + + {{ end }} +
+
+{{ end }} diff --git a/appview/pages/templates/repo/tags.html b/appview/pages/templates/repo/tags.html --- a/appview/pages/templates/repo/tags.html +++ b/appview/pages/templates/repo/tags.html @@ -11,7 +11,7 @@
- -
+
{{ if .Tag }} {{ $messageParts := splitN .Tag.Message "\n\n" 2 }} -

{{ index $messageParts 0 }}

+

{{ index $messageParts 0 }}

{{ if gt (len $messageParts) 1 }} -

{{ nl2br (index $messageParts 1) }}

+

{{ nl2br (index $messageParts 1) }}

{{ end }} + {{ block "artifacts" (list $ .) }} {{ end }} {{ else }}

no message

{{ end }} @@ -74,3 +75,91 @@ {{ end }}
{{ end }} + +{{ define "repoAfter" }} +{{ if gt (len .DanglingArtifacts) 0 }} +
+ {{ block "dangling" . }} {{ end }} +
+{{ end }} +{{ end }} + +{{ define "artifacts" }} + {{ $root := index . 0 }} + {{ $tag := index . 1 }} + {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} + {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} + + {{ if or (gt (len $artifacts) 0) $isPushAllowed }} +

artifacts

+
+ {{ range $artifact := $artifacts }} + {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} + {{ template "repo/fragments/artifact" $args }} + {{ end }} + {{ if $isPushAllowed }} + {{ block "uploadArtifact" (list $root $tag) }} {{ end }} + {{ end }} +
+ {{ end }} +{{ end }} + +{{ define "uploadArtifact" }} +{{ $root := index . 0 }} +{{ $tag := index . 1 }} +{{ $unique := $tag.Tag.Target.String }} +
+
+ + +
+
+ +
+
+{{ end }} + +{{ define "dangling" }} + {{ $root := . }} + {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} + {{ $artifacts := $root.DanglingArtifacts }} + + {{ if and (gt (len $artifacts) 0) $isPushAllowed }} +

dangling artifacts

+

The tags that these artifacts were attached to have been deleted. These artifacts are only visible to collaborators.

+
+ {{ range $artifact := $artifacts }} + {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} + {{ template "repo/fragments/artifact" $args }} + {{ end }} +
+ {{ end }} +{{ end }} diff --git a/appview/state/artifact.go b/appview/state/artifact.go new file mode 100644 --- /dev/null +++ b/appview/state/artifact.go @@ -0,0 +1,280 @@ +package state + +import ( + "fmt" + "log" + "net/http" + "time" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/dustin/go-humanize" + "github.com/go-chi/chi/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/ipfs/go-cid" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/appview" + "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/pages" + "tangled.sh/tangled.sh/core/types" +) + +// TODO: proper statuses here on early exit +func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + tagParam := chi.URLParam(r, "tag") + f, err := fullyResolvedRepo(r) + if err != nil { + log.Println("failed to get repo and knot", err) + s.pages.Notice(w, "upload", "failed to upload artifact, error in repo resolution") + return + } + + tag, err := s.resolveTag(f, tagParam) + if err != nil { + log.Println("failed to resolve tag", err) + s.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") + return + } + + file, handler, err := r.FormFile("artifact") + if err != nil { + log.Println("failed to upload artifact", err) + s.pages.Notice(w, "upload", "failed to upload artifact") + return + } + defer file.Close() + + client, _ := s.auth.AuthorizedClient(r) + + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) + if err != nil { + log.Println("failed to upload blob", err) + s.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") + return + } + + log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) + + rkey := appview.TID() + createdAt := time.Now() + + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoArtifactNSID, + Repo: user.Did, + Rkey: rkey, + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.RepoArtifact{ + Artifact: uploadBlobResp.Blob, + CreatedAt: createdAt.Format(time.RFC3339), + Name: handler.Filename, + Repo: f.RepoAt.String(), + Tag: tag.Tag.Hash[:], + }, + }, + }) + if err != nil { + log.Println("failed to create record", err) + s.pages.Notice(w, "upload", "Failed to create artifact record. Try again later.") + return + } + + log.Println(putRecordResp.Uri) + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + log.Println("failed to start tx") + s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") + return + } + defer tx.Rollback() + + artifact := db.Artifact{ + Did: user.Did, + Rkey: rkey, + RepoAt: f.RepoAt, + Tag: tag.Tag.Hash, + CreatedAt: createdAt, + BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), + Name: handler.Filename, + Size: uint64(uploadBlobResp.Blob.Size), + Mimetype: uploadBlobResp.Blob.MimeType, + } + + err = db.AddArtifact(tx, artifact) + if err != nil { + log.Println("failed to add artifact record to db", err) + s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") + return + } + + err = tx.Commit() + if err != nil { + log.Println("failed to add artifact record to db") + s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") + return + } + + s.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(s, user), + Artifact: artifact, + }) +} + +// TODO: proper statuses here on early exit +func (s *State) DownloadArtifact(w http.ResponseWriter, r *http.Request) { + tagParam := chi.URLParam(r, "tag") + filename := chi.URLParam(r, "file") + f, err := fullyResolvedRepo(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + tag, err := s.resolveTag(f, tagParam) + if err != nil { + log.Println("failed to resolve tag", err) + s.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") + return + } + + client, _ := s.auth.AuthorizedClient(r) + + artifacts, err := db.GetArtifact( + s.db, + db.NewFilter("repo_at", f.RepoAt), + db.NewFilter("tag", tag.Tag.Hash[:]), + db.NewFilter("name", filename), + ) + if err != nil { + log.Println("failed to get artifacts", err) + return + } + if len(artifacts) != 1 { + log.Printf("too many or too little artifacts found") + return + } + + artifact := artifacts[0] + + getBlobResp, err := comatproto.SyncGetBlob(r.Context(), client, artifact.BlobCid.String(), artifact.Did) + if err != nil { + log.Println("failed to get blob from pds", err) + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + w.Write(getBlobResp) +} + +// TODO: proper statuses here on early exit +func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + tagParam := chi.URLParam(r, "tag") + filename := chi.URLParam(r, "file") + f, err := fullyResolvedRepo(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + client, _ := s.auth.AuthorizedClient(r) + + tag := plumbing.NewHash(tagParam) + + artifacts, err := db.GetArtifact( + s.db, + db.NewFilter("repo_at", f.RepoAt), + db.NewFilter("tag", tag[:]), + db.NewFilter("name", filename), + ) + if err != nil { + log.Println("failed to get artifacts", err) + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") + return + } + if len(artifacts) != 1 { + s.pages.Notice(w, "remove", "Unable to find artifact.") + return + } + + artifact := artifacts[0] + + if user.Did != artifact.Did { + log.Println("user not authorized to delete artifact", err) + s.pages.Notice(w, "remove", "Unauthorized deletion of artifact.") + return + } + + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ + Collection: tangled.RepoArtifactNSID, + Repo: user.Did, + Rkey: artifact.Rkey, + }) + if err != nil { + log.Println("failed to get blob from pds", err) + s.pages.Notice(w, "remove", "Failed to remove blob from PDS.") + return + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + log.Println("failed to start tx") + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") + return + } + defer tx.Rollback() + + err = db.RemoveArtifact(tx, + db.NewFilter("repo_at", f.RepoAt), + db.NewFilter("tag", artifact.Tag[:]), + db.NewFilter("name", filename), + ) + if err != nil { + log.Println("failed to remove artifact record from db", err) + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") + return + } + + err = tx.Commit() + if err != nil { + log.Println("failed to remove artifact record from db") + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") + return + } + + w.Write([]byte{}) +} + +func (s *State) resolveTag(f *FullyResolvedRepo, tagParam string) (*types.TagReference, error) { + us, err := NewUnsignedClient(f.Knot, s.config.Dev) + if err != nil { + return nil, err + } + + result, err := us.Tags(f.OwnerDid(), f.RepoName) + if err != nil { + log.Println("failed to reach knotserver", err) + return nil, err + } + + var tag *types.TagReference + for _, t := range result.Tags { + if t.Tag != nil { + if t.Reference.Name == tagParam || t.Reference.Hash == tagParam { + tag = t + } + } + } + + if tag == nil { + return nil, fmt.Errorf("invalid tag, only annotated tags are supported for artifacts") + } + + if tag.Tag.Target.IsZero() { + return nil, fmt.Errorf("invalid tag, only annotated tags are supported for artifacts") + } + + return tag, nil +} diff --git a/appview/state/follow.go b/appview/state/follow.go --- a/appview/state/follow.go +++ b/appview/state/follow.go @@ -7,7 +7,7 @@ "time" comatproto "github.com/bluesky-social/indigo/api/atproto" lexutil "github.com/bluesky-social/indigo/lex/util" - tangled "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/pages" diff --git a/appview/state/jetstream.go b/appview/state/jetstream.go new file mode 100644 --- /dev/null +++ b/appview/state/jetstream.go @@ -0,0 +1,70 @@ +package state + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/jetstream/pkg/models" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/appview/db" +) + +type Ingester func(ctx context.Context, e *models.Event) error + +func jetstreamIngester(d db.DbWrapper) Ingester { + return func(ctx context.Context, e *models.Event) error { + var err error + defer func() { + eventTime := e.TimeUS + lastTimeUs := eventTime + 1 + if err := d.SaveLastTimeUs(lastTimeUs); err != nil { + err = fmt.Errorf("(deferred) failed to save last time us: %w", err) + } + }() + + if e.Kind != models.EventKindCommit { + return nil + } + + did := e.Did + raw := json.RawMessage(e.Commit.Record) + + switch e.Commit.Collection { + case tangled.GraphFollowNSID: + record := tangled.GraphFollow{} + err := json.Unmarshal(raw, &record) + if err != nil { + log.Println("invalid record") + return err + } + err = db.AddFollow(d, did, record.Subject, e.Commit.RKey) + if err != nil { + return fmt.Errorf("failed to add follow to db: %w", err) + } + case tangled.FeedStarNSID: + record := tangled.FeedStar{} + err := json.Unmarshal(raw, &record) + if err != nil { + log.Println("invalid record") + return err + } + + subjectUri, err := syntax.ParseATURI(record.Subject) + + if err != nil { + log.Println("invalid record") + return err + } + + err = db.AddStar(d, did, subjectUri, e.Commit.RKey) + if err != nil { + return fmt.Errorf("failed to add follow to db: %w", err) + } + } + + return err + } +} diff --git a/appview/state/repo.go b/appview/state/repo.go --- a/appview/state/repo.go +++ b/appview/state/repo.go @@ -16,12 +16,6 @@ "strconv" "strings" "time" - "github.com/bluesky-social/indigo/atproto/data" - "github.com/bluesky-social/indigo/atproto/identity" - "github.com/bluesky-social/indigo/atproto/syntax" - securejoin "github.com/cyphar/filepath-securejoin" - "github.com/go-chi/chi/v5" - "github.com/go-git/go-git/v5/plumbing" "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview" "tangled.sh/tangled.sh/core/appview/auth" @@ -31,6 +25,13 @@ "tangled.sh/tangled.sh/core/appview/pages/markup" "tangled.sh/tangled.sh/core/appview/pages/repoinfo" "tangled.sh/tangled.sh/core/appview/pagination" "tangled.sh/tangled.sh/core/types" + + "github.com/bluesky-social/indigo/atproto/data" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/go-chi/chi/v5" + "github.com/go-git/go-git/v5/plumbing" comatproto "github.com/bluesky-social/indigo/api/atproto" lexutil "github.com/bluesky-social/indigo/lex/util" @@ -171,22 +172,9 @@ log.Println("failed to parse json response", err) return } - resp, err = us.Tags(f.OwnerDid(), f.RepoName) + result, err := us.Tags(f.OwnerDid(), f.RepoName) if err != nil { log.Println("failed to reach knotserver", err) - return - } - - body, err = io.ReadAll(resp.Body) - if err != nil { - log.Printf("error reading response body: %v", err) - return - } - - var result types.RepoTagsResponse - err = json.Unmarshal(body, &result) - if err != nil { - log.Printf("Error unmarshalling response body: %v", err) return } @@ -426,30 +414,47 @@ log.Println("failed to create unsigned client", err) return } - resp, err := us.Tags(f.OwnerDid(), f.RepoName) + result, err := us.Tags(f.OwnerDid(), f.RepoName) if err != nil { log.Println("failed to reach knotserver", err) return } - body, err := io.ReadAll(resp.Body) + artifacts, err := db.GetArtifact(s.db, db.NewFilter("repo_at", f.RepoAt)) if err != nil { - log.Printf("Error reading response body: %v", err) + log.Println("failed grab artifacts", err) return } - var result types.RepoTagsResponse - err = json.Unmarshal(body, &result) - if err != nil { - log.Println("failed to parse response:", err) - return + // convert artifacts to map for easy UI building + artifactMap := make(map[plumbing.Hash][]db.Artifact) + for _, a := range artifacts { + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) + } + + var danglingArtifacts []db.Artifact + for _, a := range artifacts { + found := false + for _, t := range result.Tags { + if t.Tag != nil { + if t.Tag.Hash == a.Tag { + found = true + } + } + } + + if !found { + danglingArtifacts = append(danglingArtifacts, a) + } } user := s.auth.GetUser(r) s.pages.RepoTags(w, pages.RepoTagsParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), - RepoTagsResponse: result, + LoggedInUser: user, + RepoInfo: f.RepoInfo(s, user), + RepoTagsResponse: *result, + ArtifactMap: artifactMap, + DanglingArtifacts: danglingArtifacts, }) return } diff --git a/appview/state/router.go b/appview/state/router.go --- a/appview/state/router.go +++ b/appview/state/router.go @@ -63,7 +63,24 @@ r.Get("/*", s.RepoTree) }) r.Get("/commit/{ref}", s.RepoCommit) r.Get("/branches", s.RepoBranches) - r.Get("/tags", s.RepoTags) + r.Route("/tags", func(r chi.Router) { + r.Get("/", s.RepoTags) + r.Route("/{tag}", func(r chi.Router) { + r.Use(middleware.AuthMiddleware(s.auth)) + // require auth to download for now + r.Get("/download/{file}", s.DownloadArtifact) + + // require repo:push to upload or delete artifacts + // + // additionally: only the uploader can truly delete an artifact + // (record+blob will live on their pds) + r.Group(func(r chi.Router) { + r.With(RepoPermissionMiddleware(s, "repo:push")) + r.Post("/upload", s.AttachArtifact) + r.Delete("/{file}", s.DeleteArtifact) + }) + }) + }) r.Get("/blob/{ref}/*", s.RepoBlob) r.Get("/raw/{ref}/*", s.RepoBlobRaw) diff --git a/appview/state/signer.go b/appview/state/signer.go --- a/appview/state/signer.go +++ b/appview/state/signer.go @@ -350,7 +350,7 @@ return us.client.Do(req) } -func (us *UnsignedClient) Tags(ownerDid, repoName string) (*http.Response, error) { +func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) { const ( Method = "GET" ) @@ -362,7 +362,23 @@ if err != nil { return nil, err } - return us.client.Do(req) + resp, err := us.client.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result types.RepoTagsResponse + err = json.Unmarshal(body, &result) + if err != nil { + return nil, err + } + + return &result, nil } func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*http.Response, error) { diff --git a/appview/state/star.go b/appview/state/star.go --- a/appview/state/star.go +++ b/appview/state/star.go @@ -8,7 +8,7 @@ comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" - tangled "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/pages" diff --git a/appview/state/state.go b/appview/state/state.go --- a/appview/state/state.go +++ b/appview/state/state.go @@ -17,7 +17,7 @@ "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" - tangled "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview" "tangled.sh/tangled.sh/core/appview/auth" "tangled.sh/tangled.sh/core/appview/db" diff --git a/cmd/gen.go b/cmd/gen.go --- a/cmd/gen.go +++ b/cmd/gen.go @@ -2,7 +2,7 @@ package main import ( cbg "github.com/whyrusleeping/cbor-gen" - shtangled "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/api/tangled" ) func main() { @@ -14,18 +14,19 @@ if err := genCfg.WriteMapEncodersToFile( "api/tangled/cbor_gen.go", "tangled", - shtangled.FeedStar{}, - shtangled.GraphFollow{}, - shtangled.KnotMember{}, - shtangled.PublicKey{}, - shtangled.RepoIssueComment{}, - shtangled.RepoIssueState{}, - shtangled.RepoIssue{}, - shtangled.Repo{}, - shtangled.RepoPull{}, - shtangled.RepoPull_Source{}, - shtangled.RepoPullStatus{}, - shtangled.RepoPullComment{}, + tangled.FeedStar{}, + tangled.GraphFollow{}, + tangled.KnotMember{}, + tangled.PublicKey{}, + tangled.RepoIssueComment{}, + tangled.RepoIssueState{}, + tangled.RepoIssue{}, + tangled.Repo{}, + tangled.RepoPull{}, + tangled.RepoPull_Source{}, + tangled.RepoPullStatus{}, + tangled.RepoPullComment{}, + tangled.RepoArtifact{}, ); err != nil { panic(err) } diff --git a/flake.nix b/flake.nix --- a/flake.nix +++ b/flake.nix @@ -446,3 +446,4 @@ ]; }; }; } + diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ github.com/go-chi/chi/v5 v5.2.0 github.com/go-git/go-git/v5 v5.14.0 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 - github.com/ipfs/go-cid v0.4.1 + github.com/ipfs/go-cid v0.5.0 github.com/mattn/go-sqlite3 v1.14.24 github.com/microcosm-cc/bluemonday v1.0.27 github.com/resend/resend-go/v2 v2.15.0 diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -132,6 +132,8 @@ github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= +github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= +github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= diff --git a/lexicons/artifact.json b/lexicons/artifact.json new file mode 100644 --- /dev/null +++ b/lexicons/artifact.json @@ -0,0 +1,52 @@ +{ + "lexicon": 1, + "id": "sh.tangled.repo.artifact", + "needsCbor": true, + "needsType": true, + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "name", + "repo", + "tag", + "createdAt", + "artifact" + ], + "properties": { + "name": { + "type": "string", + "description": "name of the artifact" + }, + "repo": { + "type": "string", + "format": "at-uri", + "description": "repo that this artifact is being uploaded to" + }, + "tag": { + "type": "bytes", + "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", + "minLength": 20, + "maxLength": 20 + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "time of creation of this artifact" + }, + "artifact": { + "type": "blob", + "description": "the artifact", + "accept": [ + "*/*" + ], + "maxSize": 1000000 + } + } + } + } + } +}