appview/repo: rework blob handler to use models.BlobView #794

merged
opened by oppi.li targeting master from push-lqyxyyrozyxs
Changed files
+152 -65
appview
pages
templates
repo
+8 -1
appview/pages/templates/repo/index.html
···
{{ end }}
{{ define "repoLanguages" }}
-
<details class="group -m-6 mb-4">
+
<details class="group -my-4 -m-6 mb-4">
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
{{ range $value := .Languages }}
<div
···
{{ $icon := "folder" }}
{{ $iconStyle := "size-4 fill-current" }}
+
{{ if .IsSubmodule }}
+
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
+
{{ $icon = "folder-input" }}
+
{{ $iconStyle = "size-4" }}
+
{{ end }}
+
{{ if .IsFile }}
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
{{ $icon = "file" }}
{{ $iconStyle = "size-4" }}
{{ end }}
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
<div class="flex items-center gap-2">
{{ i $icon $iconStyle "flex-shrink-0" }}
+8
appview/pages/templates/repo/tree.html
···
{{ $icon := "folder" }}
{{ $iconStyle := "size-4 fill-current" }}
+
{{ if .IsSubmodule }}
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
+
{{ $icon = "folder-input" }}
+
{{ $iconStyle = "size-4" }}
+
{{ end }}
+
{{ if .IsFile }}
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
{{ $icon = "file" }}
{{ $iconStyle = "size-4" }}
{{ end }}
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
<div class="flex items-center gap-2">
{{ i $icon $iconStyle "flex-shrink-0" }}
+136 -64
appview/repo/blob.go
···
package repo
import (
+
"encoding/base64"
"fmt"
"io"
"net/http"
···
"strings"
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/config"
+
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/pages/markup"
+
"tangled.org/core/appview/reporesolver"
xrpcclient "tangled.org/core/appview/xrpcclient"
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/go-chi/chi/v5"
)
+
// the content can be one of the following:
+
//
+
// - code : text | | raw
+
// - markup : text | rendered | raw
+
// - svg : text | rendered | raw
+
// - png : | rendered | raw
+
// - video : | rendered | raw
+
// - submodule : | rendered |
+
// - rest : | |
func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
l := rp.logger.With("handler", "RepoBlob")
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
l.Error("failed to get repo and knot", "err", err)
return
}
+
ref := chi.URLParam(r, "ref")
ref, _ = url.PathUnescape(ref)
+
filePath := chi.URLParam(r, "*")
filePath, _ = url.PathUnescape(filePath)
+
scheme := "http"
if !rp.config.Core.Dev {
scheme = "https"
···
rp.pages.Error503(w)
return
}
+
// Use XRPC response directly instead of converting to internal types
var breadcrumbs [][]string
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
···
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
}
}
-
showRendered := false
-
renderToggle := false
-
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
-
renderToggle = true
-
showRendered = r.URL.Query().Get("code") != "true"
-
}
-
var unsupported bool
-
var isImage bool
-
var isVideo bool
-
var contentSrc string
-
if resp.IsBinary != nil && *resp.IsBinary {
-
ext := strings.ToLower(filepath.Ext(resp.Path))
-
switch ext {
-
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
-
isImage = true
-
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
-
isVideo = true
-
default:
-
unsupported = true
-
}
-
// fetch the raw binary content using sh.tangled.repo.blob xrpc
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
baseURL := &url.URL{
-
Scheme: scheme,
-
Host: f.Knot,
-
Path: "/xrpc/sh.tangled.repo.blob",
-
}
-
query := baseURL.Query()
-
query.Set("repo", repoName)
-
query.Set("ref", ref)
-
query.Set("path", filePath)
-
query.Set("raw", "true")
-
baseURL.RawQuery = query.Encode()
-
blobURL := baseURL.String()
-
contentSrc = blobURL
-
if !rp.config.Core.Dev {
-
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
-
}
-
}
-
lines := 0
-
if resp.IsBinary == nil || !*resp.IsBinary {
-
lines = strings.Count(resp.Content, "\n") + 1
-
}
-
var sizeHint uint64
-
if resp.Size != nil {
-
sizeHint = uint64(*resp.Size)
-
} else {
-
sizeHint = uint64(len(resp.Content))
-
}
+
+
// Create the blob view
+
blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
+
user := rp.oauth.GetUser(r)
-
// Determine if content is binary (dereference pointer)
-
isBinary := false
-
if resp.IsBinary != nil {
-
isBinary = *resp.IsBinary
-
}
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
BreadCrumbs: breadcrumbs,
-
ShowRendered: showRendered,
-
RenderToggle: renderToggle,
-
Unsupported: unsupported,
-
IsImage: isImage,
-
IsVideo: isVideo,
-
ContentSrc: contentSrc,
+
BlobView: blobView,
RepoBlob_Output: resp,
-
Contents: resp.Content,
-
Lines: lines,
-
SizeHint: sizeHint,
-
IsBinary: isBinary,
})
}
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
l := rp.logger.With("handler", "RepoBlobRaw")
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
l.Error("failed to get repo and knot", "err", err)
w.WriteHeader(http.StatusBadRequest)
return
}
+
ref := chi.URLParam(r, "ref")
ref, _ = url.PathUnescape(ref)
+
filePath := chi.URLParam(r, "*")
filePath, _ = url.PathUnescape(filePath)
+
scheme := "http"
if !rp.config.Core.Dev {
scheme = "https"
···
l.Error("failed to create request", "err", err)
return
}
+
// forward the If-None-Match header
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
req.Header.Set("If-None-Match", clientETag)
}
client := &http.Client{}
+
resp, err := client.Do(req)
if err != nil {
l.Error("failed to reach knotserver", "err", err)
rp.pages.Error503(w)
return
}
+
defer resp.Body.Close()
+
// forward 304 not modified
if resp.StatusCode == http.StatusNotModified {
w.WriteHeader(http.StatusNotModified)
return
}
+
if resp.StatusCode != http.StatusOK {
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}
+
contentType := resp.Header.Get("Content-Type")
body, err := io.ReadAll(resp.Body)
if err != nil {
···
w.WriteHeader(http.StatusInternalServerError)
return
}
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
// serve all textual content as text/plain
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
···
}
}
+
// NewBlobView creates a BlobView from the XRPC response
+
func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView {
+
view := models.BlobView{
+
Contents: "",
+
Lines: 0,
+
}
+
+
// Set size
+
if resp.Size != nil {
+
view.SizeHint = uint64(*resp.Size)
+
} else if resp.Content != nil {
+
view.SizeHint = uint64(len(*resp.Content))
+
}
+
+
if resp.Submodule != nil {
+
view.ContentType = models.BlobContentTypeSubmodule
+
view.HasRenderedView = true
+
view.ContentSrc = resp.Submodule.Url
+
return view
+
}
+
+
// Determine if binary
+
if resp.IsBinary != nil && *resp.IsBinary {
+
view.ContentSrc = generateBlobURL(config, f, ref, filePath)
+
ext := strings.ToLower(filepath.Ext(resp.Path))
+
+
switch ext {
+
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
+
view.ContentType = models.BlobContentTypeImage
+
view.HasRawView = true
+
view.HasRenderedView = true
+
view.ShowingRendered = true
+
+
case ".svg":
+
view.ContentType = models.BlobContentTypeSvg
+
view.HasRawView = true
+
view.HasTextView = true
+
view.HasRenderedView = true
+
view.ShowingRendered = queryParams.Get("code") != "true"
+
if resp.Content != nil {
+
bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
+
view.Contents = string(bytes)
+
view.Lines = strings.Count(view.Contents, "\n") + 1
+
}
+
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
+
view.ContentType = models.BlobContentTypeVideo
+
view.HasRawView = true
+
view.HasRenderedView = true
+
view.ShowingRendered = true
+
}
+
+
return view
+
}
+
+
// otherwise, we are dealing with text content
+
view.HasRawView = true
+
view.HasTextView = true
+
+
if resp.Content != nil {
+
view.Contents = *resp.Content
+
view.Lines = strings.Count(view.Contents, "\n") + 1
+
}
+
+
// with text, we may be dealing with markdown
+
format := markup.GetFormat(resp.Path)
+
if format == markup.FormatMarkdown {
+
view.ContentType = models.BlobContentTypeMarkup
+
view.HasRenderedView = true
+
view.ShowingRendered = queryParams.Get("code") != "true"
+
}
+
+
return view
+
}
+
+
func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string {
+
scheme := "http"
+
if !config.Core.Dev {
+
scheme = "https"
+
}
+
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
baseURL := &url.URL{
+
Scheme: scheme,
+
Host: f.Knot,
+
Path: "/xrpc/sh.tangled.repo.blob",
+
}
+
query := baseURL.Query()
+
query.Set("repo", repoName)
+
query.Set("ref", ref)
+
query.Set("path", filePath)
+
query.Set("raw", "true")
+
baseURL.RawQuery = query.Encode()
+
blobURL := baseURL.String()
+
+
if !config.Core.Dev {
+
return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL)
+
}
+
return blobURL
+
}
+
func isTextualMimeType(mimeType string) bool {
textualTypes := []string{
"application/json",