Monorepo for Tangled
at 27a3e035657a8dcf3230fb916bcf479addaba4ec 187 lines 4.7 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.GetLastCommitForPath(ctx, treePath) 151 if err == nil && lastCommit != nil { 152 shortHash := lastCommit.Hash.String()[:8] 153 response.LastCommit = &tangled.RepoBlob_LastCommit{ 154 Hash: lastCommit.Hash.String(), 155 ShortHash: &shortHash, 156 Message: lastCommit.Message, 157 When: lastCommit.When.Format(time.RFC3339), 158 } 159 160 // try to get author information 161 commit, err := gr.Commit(lastCommit.Hash) 162 if err == nil { 163 response.LastCommit.Author = &tangled.RepoBlob_Signature{ 164 Name: commit.Author.Name, 165 Email: commit.Author.Email, 166 } 167 } 168 } 169 170 writeJson(w, response) 171} 172 173// isTextualMimeType returns true if the MIME type represents textual content 174// that should be served as text/plain for security reasons 175func isTextualMimeType(mimeType string) bool { 176 textualTypes := []string{ 177 "application/json", 178 "application/xml", 179 "application/yaml", 180 "application/x-yaml", 181 "application/toml", 182 "application/javascript", 183 "application/ecmascript", 184 } 185 186 return slices.Contains(textualTypes, mimeType) 187}