forked from tangled.org/core
this repo has no description

appview: introduce release artifacts

+291
api/tangled/cbor_gen.go
···
"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"
···
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
+
}
+31
api/tangled/repoartifact.go
···
+
// 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"`
+
}
+166
appview/db/artifact.go
···
+
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
+
}
+23
appview/db/db.go
···
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
+1 -1
appview/db/pulls.go
···
"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"
)
+13
appview/pages/pages.go
···
"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"
)
···
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 {
+34
appview/pages/templates/repo/fragments/artifact.html
···
+
{{ define "repo/fragments/artifact" }}
+
{{ $unique := .Artifact.BlobCid.String }}
+
<div id="artifact-{{ $unique }}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
+
<div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]">
+
{{ i "box" "w-4 h-4" }}
+
<a href="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}" class="no-underline hover:no-underline">
+
{{ .Artifact.Name }}
+
</a>
+
<span class="text-gray-500 dark:text-gray-400 pl-2">{{ byteFmt .Artifact.Size }}</span>
+
</div>
+
+
<div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2">
+
<span title="{{ longTimeFmt .Artifact.CreatedAt }}" class="hidden md:inline">{{ timeFmt .Artifact.CreatedAt }}</span>
+
<span title="{{ longTimeFmt .Artifact.CreatedAt }}" class=" md:hidden">{{ shortTimeFmt .Artifact.CreatedAt }}</span>
+
+
<span class="select-none after:content-['·'] hidden md:inline"></span>
+
<span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.Mimetype }}</span>
+
+
{{ if and (.LoggedInUser) (eq .LoggedInUser.Did .Artifact.Did) }}
+
<button
+
id="delete-{{ $unique }}"
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2"
+
title="Delete artifact"
+
hx-delete="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/{{ .Artifact.Name | urlquery }}"
+
hx-swap="outerHTML"
+
hx-target="#artifact-{{ $unique }}"
+
hx-disabled-elt="#delete-{{ $unique }}"
+
hx-confirm="Are you sure you want to delete the artifact '{{ .Artifact.Name }}'?">
+
{{ i "trash-2" "w-4 h-4" }}
+
</button>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+93 -4
appview/pages/templates/repo/tags.html
···
<!-- Header column (top on mobile, left on md+) -->
<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">
<!-- Mobile layout: horizontal -->
-
<div class="flex md:hidden flex-col py-2 px-2">
+
<div class="flex md:hidden flex-col py-2 px-2 text-xl">
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold">
{{ i "tag" "w-4 h-4" }}
{{ .Name }}
···
</div>
<!-- Content column (bottom on mobile, right on md+) -->
-
<div class="md:col-span-9 px-2 py-3 md:py-0 md:pb-6">
+
<div class="md:col-span-10 px-2 py-3 md:py-0 md:pb-6">
{{ if .Tag }}
{{ $messageParts := splitN .Tag.Message "\n\n" 2 }}
-
<p class="font-bold">{{ index $messageParts 0 }}</p>
+
<p class="font-bold text-lg">{{ index $messageParts 0 }}</p>
{{ if gt (len $messageParts) 1 }}
-
<p class="cursor-text pb-2 text-sm">{{ nl2br (index $messageParts 1) }}</p>
+
<p class="cursor-text py-2">{{ nl2br (index $messageParts 1) }}</p>
{{ end }}
+
{{ block "artifacts" (list $ .) }} {{ end }}
{{ else }}
<p class="italic text-gray-500 dark:text-gray-400">no message</p>
{{ end }}
···
</div>
</section>
{{ end }}
+
+
{{ define "repoAfter" }}
+
{{ if gt (len .DanglingArtifacts) 0 }}
+
<section class="bg-white dark:bg-gray-800 p-6 mt-4">
+
{{ block "dangling" . }} {{ end }}
+
</section>
+
{{ 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 }}
+
<h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2>
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700">
+
{{ 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 }}
+
</div>
+
{{ end }}
+
{{ end }}
+
+
{{ define "uploadArtifact" }}
+
{{ $root := index . 0 }}
+
{{ $tag := index . 1 }}
+
{{ $unique := $tag.Tag.Target.String }}
+
<form
+
id="upload-{{$unique}}"
+
method="post"
+
enctype="multipart/form-data"
+
hx-post="/{{ $root.RepoInfo.FullName }}/tags/{{ $tag.Name | urlquery }}/upload"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
hx-disabled-elt="#upload-btn-{{$unique}}"
+
hx-swap="beforebegin"
+
hx-target="this"
+
class="flex items-center gap-2 px-2">
+
<div class="flex-grow">
+
<input type="file"
+
name="artifact"
+
required
+
class="block py-2 px-0 w-full border-none
+
text-black dark:text-white
+
bg-white dark:bg-gray-800
+
file:mr-4 file:px-2 file:py-2
+
file:rounded file:border-0
+
file:text-sm file:font-medium
+
file:text-gray-700 file:dark:text-gray-300
+
file:bg-gray-200 file:dark:bg-gray-700
+
file:hover:bg-gray-100 file:hover:dark:bg-gray-600
+
">
+
</input>
+
</div>
+
<div class="flex justify-end">
+
<button
+
type="submit"
+
class="btn gap-2"
+
id="upload-btn-{{$unique}}"
+
title="Upload artifact">
+
{{ i "upload" "w-4 h-4" }}
+
<span class="hidden md:inline">upload</span>
+
</button>
+
</div>
+
</form>
+
{{ end }}
+
+
{{ define "dangling" }}
+
{{ $root := . }}
+
{{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }}
+
{{ $artifacts := $root.DanglingArtifacts }}
+
+
{{ if and (gt (len $artifacts) 0) $isPushAllowed }}
+
<h2 class="mb-2 text-sm text-left text-red-700 dark:text-red-400 uppercase font-bold">dangling artifacts</h2>
+
<p class="mb-4">The tags that these artifacts were attached to have been deleted. These artifacts are only visible to collaborators.</p>
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700">
+
{{ range $artifact := $artifacts }}
+
{{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }}
+
{{ template "repo/fragments/artifact" $args }}
+
{{ end }}
+
</div>
+
{{ end }}
+
{{ end }}
+280
appview/state/artifact.go
···
+
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
+
}
+1 -1
appview/state/follow.go
···
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"
+70
appview/state/jetstream.go
···
+
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
+
}
+
}
+36 -31
appview/state/repo.go
···
"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"
···
"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"
···
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
}
···
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
}
+18 -1
appview/state/router.go
···
})
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)
+18 -2
appview/state/signer.go
···
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"
)
···
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) {
+1 -1
appview/state/star.go
···
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"
+1 -1
appview/state/state.go
···
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"
+14 -13
cmd/gen.go
···
import (
cbg "github.com/whyrusleeping/cbor-gen"
-
shtangled "tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/api/tangled"
)
func main() {
···
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)
}
+1
flake.nix
···
};
};
}
+
+1 -1
go.mod
···
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
+2
go.sum
···
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=
+52
lexicons/artifact.json
···
+
{
+
"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
+
}
+
}
+
}
+
}
+
}
+
}