Monorepo for Tangled
at push-pzoouymnzvnq 185 lines 4.6 kB view raw
1package xrpc 2 3import ( 4 "context" 5 "crypto/sha256" 6 "encoding/base64" 7 "fmt" 8 "net/http" 9 "path/filepath" 10 "slices" 11 "strings" 12 "time" 13 14 "tangled.org/core/api/tangled" 15 "tangled.org/core/knotserver/git" 16 xrpcerr "tangled.org/core/xrpc/errors" 17) 18 19func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 20 repo := r.URL.Query().Get("repo") 21 repoPath, err := x.parseRepoParam(repo) 22 if err != nil { 23 writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 24 return 25 } 26 27 ref := r.URL.Query().Get("ref") 28 // ref can be empty (git.Open handles this) 29 30 treePath := r.URL.Query().Get("path") 31 if treePath == "" { 32 writeError(w, xrpcerr.NewXrpcError( 33 xrpcerr.WithTag("InvalidRequest"), 34 xrpcerr.WithMessage("missing path parameter"), 35 ), http.StatusBadRequest) 36 return 37 } 38 39 raw := r.URL.Query().Get("raw") == "true" 40 41 gr, err := git.Open(repoPath, ref) 42 if err != nil { 43 writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 44 return 45 } 46 47 // first check if this path is a submodule 48 submodule, err := gr.Submodule(treePath) 49 if err != nil { 50 // this is okay, continue and try to treat it as a regular file 51 } else { 52 response := tangled.RepoBlob_Output{ 53 Ref: ref, 54 Path: treePath, 55 Submodule: &tangled.RepoBlob_Submodule{ 56 Name: submodule.Name, 57 Url: submodule.URL, 58 Branch: &submodule.Branch, 59 }, 60 } 61 writeJson(w, response) 62 return 63 } 64 65 contents, err := gr.RawContent(treePath) 66 if err != nil { 67 x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 68 writeError(w, xrpcerr.NewXrpcError( 69 xrpcerr.WithTag("FileNotFound"), 70 xrpcerr.WithMessage("file not found at the specified path"), 71 ), http.StatusNotFound) 72 return 73 } 74 75 mimeType := http.DetectContentType(contents) 76 77 if filepath.Ext(treePath) == ".svg" { 78 mimeType = "image/svg+xml" 79 } 80 81 if raw { 82 contentHash := sha256.Sum256(contents) 83 eTag := fmt.Sprintf("\"%x\"", contentHash) 84 85 switch { 86 case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 87 if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 88 w.WriteHeader(http.StatusNotModified) 89 return 90 } 91 w.Header().Set("ETag", eTag) 92 w.Header().Set("Content-Type", mimeType) 93 94 case strings.HasPrefix(mimeType, "text/"): 95 w.Header().Set("Cache-Control", "public, no-cache") 96 // serve all text content as text/plain 97 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 98 99 case isTextualMimeType(mimeType): 100 // handle textual application types (json, xml, etc.) as text/plain 101 w.Header().Set("Cache-Control", "public, no-cache") 102 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 103 104 default: 105 x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType) 106 writeError(w, xrpcerr.NewXrpcError( 107 xrpcerr.WithTag("InvalidRequest"), 108 xrpcerr.WithMessage("only image, video, and text files can be accessed directly"), 109 ), http.StatusForbidden) 110 return 111 } 112 w.Write(contents) 113 return 114 } 115 116 isTextual := func(mt string) bool { 117 return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt) 118 } 119 120 var content string 121 var encoding string 122 123 isBinary := !isTextual(mimeType) 124 size := int64(len(contents)) 125 126 if isBinary { 127 content = base64.StdEncoding.EncodeToString(contents) 128 encoding = "base64" 129 } else { 130 content = string(contents) 131 encoding = "utf-8" 132 } 133 134 response := tangled.RepoBlob_Output{ 135 Ref: ref, 136 Path: treePath, 137 Content: &content, 138 Encoding: &encoding, 139 Size: &size, 140 IsBinary: &isBinary, 141 } 142 143 if mimeType != "" { 144 response.MimeType = &mimeType 145 } 146 147 ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) 148 defer cancel() 149 150 lastCommit, err := gr.LastCommitFile(ctx, treePath) 151 if err == nil && lastCommit != nil { 152 response.LastCommit = &tangled.RepoBlob_LastCommit{ 153 Hash: lastCommit.Hash.String(), 154 Message: lastCommit.Message, 155 When: lastCommit.When.Format(time.RFC3339), 156 } 157 158 // try to get author information 159 commit, err := gr.Commit(lastCommit.Hash) 160 if err == nil { 161 response.LastCommit.Author = &tangled.RepoBlob_Signature{ 162 Name: commit.Author.Name, 163 Email: commit.Author.Email, 164 } 165 } 166 } 167 168 writeJson(w, response) 169} 170 171// isTextualMimeType returns true if the MIME type represents textual content 172// that should be served as text/plain for security reasons 173func isTextualMimeType(mimeType string) bool { 174 textualTypes := []string{ 175 "application/json", 176 "application/xml", 177 "application/yaml", 178 "application/x-yaml", 179 "application/toml", 180 "application/javascript", 181 "application/ecmascript", 182 } 183 184 return slices.Contains(textualTypes, mimeType) 185}