knotserver: also serve text/plain in BlobRaw #390

closed
opened by oppi.li targeting master from push-svtkmrzmrwky
Changed files
+88 -24
appview
pages
markup
templates
repo
repo
knotserver
+11 -3
knotserver/routes.go
··· 286 286 mimeType = "image/svg+xml" 287 287 } 288 288 289 - if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") { 290 - l.Error("attempted to serve non-image/video file", "mimetype", mimeType) 291 - writeError(w, "only image and video files can be accessed directly", http.StatusForbidden) 289 + // allow image, video, and text/plain files to be served directly 290 + switch { 291 + case strings.HasPrefix(mimeType, "image/"): 292 + // allowed 293 + case strings.HasPrefix(mimeType, "video/"): 294 + // allowed 295 + case strings.HasPrefix(mimeType, "text/plain"): 296 + // allowed 297 + default: 298 + l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 299 + writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 292 300 return 293 301 } 294 302
+2 -2
appview/pages/markup/camo.go
··· 9 9 "github.com/yuin/goldmark/ast" 10 10 ) 11 11 12 - func generateCamoURL(baseURL, secret, imageURL string) string { 12 + func GenerateCamoURL(baseURL, secret, imageURL string) string { 13 13 h := hmac.New(sha256.New, []byte(secret)) 14 14 h.Write([]byte(imageURL)) 15 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 24 } 25 25 26 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 - return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 27 + return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 28 } 29 29 30 30 return dst
+4
appview/pages/pages.go
··· 624 624 LoggedInUser *oauth.User 625 625 RepoInfo repoinfo.RepoInfo 626 626 Active string 627 + Unsupported bool 628 + IsImage bool 629 + IsVideo bool 630 + ContentSrc string 627 631 BreadCrumbs [][]string 628 632 ShowRendered bool 629 633 RenderToggle bool
+19 -6
appview/pages/templates/repo/blob.html
··· 5 5 6 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 - 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 - 10 + 11 11 {{ end }} 12 12 13 13 {{ define "repoContent" }} ··· 44 44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 45 {{ if .RenderToggle }} 46 46 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 47 + <a 48 + href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 49 hx-boost="true" 50 50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 51 {{ end }} 52 52 </div> 53 53 </div> 54 54 </div> 55 - {{ if .IsBinary }} 55 + {{ if and .IsBinary .Unsupported }} 56 56 <p class="text-center text-gray-400 dark:text-gray-500"> 57 - This is a binary file and will not be displayed. 57 + Previews are not supported for this file type. 58 58 </p> 59 + {{ else if .IsBinary }} 60 + <div class="text-center"> 61 + {{ if .IsImage }} 62 + <img src="{{ .ContentSrc }}" 63 + alt="{{ .Path }}" 64 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 65 + {{ else if .IsVideo }} 66 + <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 67 + <source src="{{ .ContentSrc }}"> 68 + Your browser does not support the video tag. 69 + </video> 70 + {{ end }} 71 + </div> 59 72 {{ else }} 60 73 <div class="overflow-auto relative"> 61 74 {{ if .ShowRendered }}
+52 -13
appview/repo/repo.go
··· 10 10 "log" 11 11 "net/http" 12 12 "net/url" 13 + "path/filepath" 13 14 "slices" 14 15 "strconv" 15 16 "strings" ··· 532 533 showRendered = r.URL.Query().Get("code") != "true" 533 534 } 534 535 536 + var unsupported bool 537 + var isImage bool 538 + var isVideo bool 539 + var contentSrc string 540 + 541 + if result.IsBinary { 542 + ext := strings.ToLower(filepath.Ext(result.Path)) 543 + switch ext { 544 + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 545 + isImage = true 546 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 547 + isVideo = true 548 + default: 549 + unsupported = true 550 + } 551 + 552 + // fetch the actual binary content like in RepoBlobRaw 553 + 554 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 555 + contentSrc = blobURL 556 + if !rp.config.Core.Dev { 557 + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 558 + } 559 + } 560 + 535 561 user := rp.oauth.GetUser(r) 536 562 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 537 563 LoggedInUser: user, ··· 540 566 BreadCrumbs: breadcrumbs, 541 567 ShowRendered: showRendered, 542 568 RenderToggle: renderToggle, 569 + Unsupported: unsupported, 570 + IsImage: isImage, 571 + IsVideo: isVideo, 572 + ContentSrc: contentSrc, 543 573 }) 544 574 } 545 575 ··· 547 577 f, err := rp.repoResolver.Resolve(r) 548 578 if err != nil { 549 579 log.Println("failed to get repo and knot", err) 580 + w.WriteHeader(http.StatusBadRequest) 550 581 return 551 582 } 552 583 ··· 557 588 if !rp.config.Core.Dev { 558 589 protocol = "https" 559 590 } 560 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 591 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 592 + resp, err := http.Get(blobURL) 561 593 if err != nil { 562 - log.Println("failed to reach knotserver", err) 594 + log.Println("failed to reach knotserver:", err) 595 + rp.pages.Error503(w) 563 596 return 564 597 } 598 + defer resp.Body.Close() 565 599 566 - body, err := io.ReadAll(resp.Body) 567 - if err != nil { 568 - log.Printf("Error reading response body: %v", err) 600 + if resp.StatusCode != http.StatusOK { 601 + log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 602 + w.WriteHeader(resp.StatusCode) 603 + _, _ = io.Copy(w, resp.Body) 569 604 return 570 605 } 571 606 572 - var result types.RepoBlobResponse 573 - err = json.Unmarshal(body, &result) 607 + contentType := resp.Header.Get("Content-Type") 608 + body, err := io.ReadAll(resp.Body) 574 609 if err != nil { 575 - log.Println("failed to parse response:", err) 610 + log.Printf("error reading response body from knotserver: %v", err) 611 + w.WriteHeader(http.StatusInternalServerError) 576 612 return 577 613 } 578 614 579 - if result.IsBinary { 580 - w.Header().Set("Content-Type", "application/octet-stream") 615 + if strings.Contains(contentType, "text/plain") { 616 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 581 617 w.Write(body) 618 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 619 + w.Header().Set("Content-Type", contentType) 620 + w.Write(body) 621 + } else { 622 + w.WriteHeader(http.StatusUnsupportedMediaType) 623 + w.Write([]byte("unsupported content type")) 582 624 return 583 625 } 584 - 585 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 586 - w.Write([]byte(result.Contents)) 587 626 } 588 627 589 628 // modify the spindle configured for this repo