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

Compare changes

Choose any two refs to compare.

+5 -65
appview/db/follow.go
···
}
}
-
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
-
if len(subjectDids) == 0 || userDid == "" {
-
return make(map[string]FollowStatus), nil
-
}
-
-
result := make(map[string]FollowStatus)
-
-
for _, subjectDid := range subjectDids {
-
if userDid == subjectDid {
-
result[subjectDid] = IsSelf
-
} else {
-
result[subjectDid] = IsNotFollowing
-
}
-
}
-
-
var querySubjects []string
-
for _, subjectDid := range subjectDids {
-
if userDid != subjectDid {
-
querySubjects = append(querySubjects, subjectDid)
-
}
-
}
-
-
if len(querySubjects) == 0 {
-
return result, nil
-
}
-
-
placeholders := make([]string, len(querySubjects))
-
args := make([]any, len(querySubjects)+1)
-
args[0] = userDid
-
-
for i, subjectDid := range querySubjects {
-
placeholders[i] = "?"
-
args[i+1] = subjectDid
-
}
-
-
query := fmt.Sprintf(`
-
SELECT subject_did
-
FROM follows
-
WHERE user_did = ? AND subject_did IN (%s)
-
`, strings.Join(placeholders, ","))
-
-
rows, err := e.Query(query, args...)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
for rows.Next() {
-
var subjectDid string
-
if err := rows.Scan(&subjectDid); err != nil {
-
return nil, err
-
}
-
result[subjectDid] = IsFollowing
-
}
-
-
return result, nil
-
}
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
-
statuses, err := getFollowStatuses(e, userDid, []string{subjectDid})
-
if err != nil {
return IsNotFollowing
}
-
return statuses[subjectDid]
-
}
-
-
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
-
return getFollowStatuses(e, userDid, subjectDids)
}
···
}
}
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
+
if userDid == subjectDid {
+
return IsSelf
+
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
return IsNotFollowing
+
} else {
+
return IsFollowing
}
}
+3 -53
appview/db/star.go
···
return stars, nil
}
-
// getStarStatuses returns a map of repo URIs to star status for a given user
-
// This is an internal helper function to avoid N+1 queries
-
func getStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
-
if len(repoAts) == 0 || userDid == "" {
-
return make(map[string]bool), nil
-
}
-
-
placeholders := make([]string, len(repoAts))
-
args := make([]any, len(repoAts)+1)
-
args[0] = userDid
-
-
for i, repoAt := range repoAts {
-
placeholders[i] = "?"
-
args[i+1] = repoAt.String()
-
}
-
-
query := fmt.Sprintf(`
-
SELECT repo_at
-
FROM stars
-
WHERE starred_by_did = ? AND repo_at IN (%s)
-
`, strings.Join(placeholders, ","))
-
-
rows, err := e.Query(query, args...)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
result := make(map[string]bool)
-
// Initialize all repos as not starred
-
for _, repoAt := range repoAts {
-
result[repoAt.String()] = false
-
}
-
-
// Mark starred repos as true
-
for rows.Next() {
-
var repoAt string
-
if err := rows.Scan(&repoAt); err != nil {
-
return nil, err
-
}
-
result[repoAt] = true
-
}
-
-
return result, nil
-
}
-
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
-
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt})
-
if err != nil {
return false
}
-
return statuses[repoAt.String()]
}
-
// GetStarStatuses returns a map of repo URIs to star status for a given user
-
func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
-
return getStarStatuses(e, userDid, repoAts)
-
}
func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
var conditions []string
var args []any
···
return stars, nil
}
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
+
if _, err := GetStar(e, userDid, repoAt); err != nil {
return false
+
} else {
+
return true
}
}
func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
var conditions []string
var args []any
+12 -58
appview/db/timeline.go
···
import (
"sort"
"time"
-
-
"github.com/bluesky-social/indigo/atproto/syntax"
)
type TimelineEvent struct {
···
// optional: populate only if event is Follow
*Profile
*FollowStats
-
*FollowStatus
-
-
// optional: populate only if event is Repo
-
IsStarred bool
-
StarCount int64
}
// TODO: this gathers heterogenous events from different sources and aggregates
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
-
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
var events []TimelineEvent
-
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
if err != nil {
return nil, err
}
···
return nil, err
}
-
follows, err := getTimelineFollows(e, limit, loggedInUserDid)
if err != nil {
return nil, err
}
···
return events, nil
}
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
repos, err := GetRepos(e, limit)
if err != nil {
return nil, err
···
uriToRepo[r.RepoAt().String()] = r
}
-
var starStatuses map[string]bool
-
if loggedInUserDid != "" {
-
var repoAts []syntax.ATURI
-
for _, r := range repos {
-
repoAts = append(repoAts, r.RepoAt())
-
}
-
var err error
-
starStatuses, err = GetStarStatuses(e, loggedInUserDid, repoAts)
-
if err != nil {
-
return nil, err
-
}
-
}
-
var events []TimelineEvent
for _, r := range repos {
var source *Repo
···
}
}
-
var isStarred bool
-
if starStatuses != nil {
-
isStarred = starStatuses[r.RepoAt().String()]
-
}
-
-
var starCount int64
-
if r.RepoStats != nil {
-
starCount = int64(r.RepoStats.StarCount)
-
}
-
events = append(events, TimelineEvent{
-
Repo: &r,
-
EventAt: r.Created,
-
Source: source,
-
IsStarred: isStarred,
-
StarCount: starCount,
})
}
···
return events, nil
}
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
follows, err := GetFollows(e, limit)
if err != nil {
return nil, err
···
return nil, err
}
-
var followStatuses map[string]FollowStatus
-
if loggedInUserDid != "" {
-
followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects)
-
if err != nil {
-
return nil, err
-
}
-
}
-
var events []TimelineEvent
for _, f := range follows {
profile, _ := profiles[f.SubjectDid]
followStatMap, _ := followStatMap[f.SubjectDid]
-
followStatus := IsNotFollowing
-
if followStatuses != nil {
-
followStatus = followStatuses[f.SubjectDid]
-
}
-
events = append(events, TimelineEvent{
-
Follow: &f,
-
Profile: profile,
-
FollowStats: &followStatMap,
-
FollowStatus: &followStatus,
-
EventAt: f.FollowedAt,
})
}
···
import (
"sort"
"time"
)
type TimelineEvent struct {
···
// optional: populate only if event is Follow
*Profile
*FollowStats
}
// TODO: this gathers heterogenous events from different sources and aggregates
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
+
func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) {
var events []TimelineEvent
+
repos, err := getTimelineRepos(e, limit)
if err != nil {
return nil, err
}
···
return nil, err
}
+
follows, err := getTimelineFollows(e, limit)
if err != nil {
return nil, err
}
···
return events, nil
}
+
func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) {
repos, err := GetRepos(e, limit)
if err != nil {
return nil, err
···
uriToRepo[r.RepoAt().String()] = r
}
var events []TimelineEvent
for _, r := range repos {
var source *Repo
···
}
}
events = append(events, TimelineEvent{
+
Repo: &r,
+
EventAt: r.Created,
+
Source: source,
})
}
···
return events, nil
}
+
func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) {
follows, err := GetFollows(e, limit)
if err != nil {
return nil, err
···
return nil, err
}
var events []TimelineEvent
for _, f := range follows {
profile, _ := profiles[f.SubjectDid]
followStatMap, _ := followStatMap[f.SubjectDid]
events = append(events, TimelineEvent{
+
Follow: &f,
+
Profile: profile,
+
FollowStats: &followStatMap,
+
EventAt: f.FollowedAt,
})
}
+18
appview/notify/merged_notifier.go
···
notifier.UpdateProfile(ctx, profile)
}
}
···
notifier.UpdateProfile(ctx, profile)
}
}
+
+
func (m *mergedNotifier) NewString(ctx context.Context, string *db.String) {
+
for _, notifier := range m.notifiers {
+
notifier.NewString(ctx, string)
+
}
+
}
+
+
func (m *mergedNotifier) EditString(ctx context.Context, string *db.String) {
+
for _, notifier := range m.notifiers {
+
notifier.EditString(ctx, string)
+
}
+
}
+
+
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
+
for _, notifier := range m.notifiers {
+
notifier.DeleteString(ctx, did, rkey)
+
}
+
}
+8
appview/notify/notifier.go
···
NewPullComment(ctx context.Context, comment *db.PullComment)
UpdateProfile(ctx context.Context, profile *db.Profile)
}
// BaseNotifier is a listener that does nothing
···
func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {}
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
···
NewPullComment(ctx context.Context, comment *db.PullComment)
UpdateProfile(ctx context.Context, profile *db.Profile)
+
+
NewString(ctx context.Context, s *db.String)
+
EditString(ctx context.Context, s *db.String)
+
DeleteString(ctx context.Context, did, rkey string)
}
// BaseNotifier is a listener that does nothing
···
func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {}
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
+
+
func (m *BaseNotifier) NewString(ctx context.Context, s *db.String) {}
+
func (m *BaseNotifier) EditString(ctx context.Context, s *db.String) {}
+
func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+90
appview/pages/templates/fragments/multiline-select.html
···
···
+
{{ define "fragments/multiline-select" }}
+
<script>
+
function highlight(scroll = false) {
+
document.querySelectorAll(".hl").forEach(el => {
+
el.classList.remove("hl");
+
});
+
+
const hash = window.location.hash;
+
if (!hash || !hash.startsWith("#L")) {
+
return;
+
}
+
+
const rangeStr = hash.substring(2);
+
const parts = rangeStr.split("-");
+
let startLine, endLine;
+
+
if (parts.length === 2) {
+
startLine = parseInt(parts[0], 10);
+
endLine = parseInt(parts[1], 10);
+
} else {
+
startLine = parseInt(parts[0], 10);
+
endLine = startLine;
+
}
+
+
if (isNaN(startLine) || isNaN(endLine)) {
+
console.log("nan");
+
console.log(startLine);
+
console.log(endLine);
+
return;
+
}
+
+
let target = null;
+
+
for (let i = startLine; i<= endLine; i++) {
+
const idEl = document.getElementById(`L${i}`);
+
if (idEl) {
+
const el = idEl.closest(".line");
+
if (el) {
+
el.classList.add("hl");
+
target = el;
+
}
+
}
+
}
+
+
if (scroll && target) {
+
target.scrollIntoView({
+
behavior: "smooth",
+
block: "center",
+
});
+
}
+
}
+
+
document.addEventListener("DOMContentLoaded", () => {
+
console.log("DOMContentLoaded");
+
highlight(true);
+
});
+
window.addEventListener("hashchange", () => {
+
console.log("hashchange");
+
highlight();
+
});
+
window.addEventListener("popstate", () => {
+
console.log("popstate");
+
highlight();
+
});
+
+
const lineNumbers = document.querySelectorAll('a[href^="#L"');
+
let startLine = null;
+
+
lineNumbers.forEach(el => {
+
el.addEventListener("click", (event) => {
+
event.preventDefault();
+
const currentLine = parseInt(el.href.split("#L")[1]);
+
+
if (event.shiftKey && startLine !== null) {
+
const endLine = currentLine;
+
const min = Math.min(startLine, endLine);
+
const max = Math.max(startLine, endLine);
+
const newHash = `#L${min}-${max}`;
+
history.pushState(null, '', newHash);
+
} else {
+
const newHash = `#L${currentLine}`;
+
history.pushState(null, '', newHash);
+
startLine = currentLine;
+
}
+
+
highlight();
+
});
+
});
+
</script>
+
{{ end }}
+7 -2
appview/pages/templates/layouts/profilebase.html
···
{{ define "content" }}
{{ template "profileTabs" . }}
-
<section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
-
<div class="md:col-span-3 order-1 md:order-1">
<div class="flex flex-col gap-4">
{{ template "user/fragments/profileCard" .Card }}
{{ block "punchcard" .Card.Punchcard }} {{ end }}
</div>
</div>
{{ block "profileContent" . }} {{ end }}
</div>
</section>
···
{{ define "content" }}
{{ template "profileTabs" . }}
+
<section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white drop-shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
+
{{ $style := "hidden md:block md:col-span-3" }}
+
{{ if eq $.Active "overview" }}
+
{{ $style = "md:col-span-3" }}
+
{{ end }}
+
<div class="{{ $style }} order-1 order-1">
<div class="flex flex-col gap-4">
{{ template "user/fragments/profileCard" .Card }}
{{ block "punchcard" .Card.Punchcard }} {{ end }}
</div>
</div>
+
{{ block "profileContent" . }} {{ end }}
</div>
</section>
+1
appview/pages/templates/repo/blob.html
···
{{ end }}
</div>
{{ end }}
{{ end }}
···
{{ end }}
</div>
{{ end }}
+
{{ template "fragments/multiline-select" }}
{{ end }}
+6 -1
appview/pages/templates/repo/fragments/shortTimeAgo.html
···
{{ define "repo/fragments/shortTimeAgo" }}
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }}
{{ end }}
···
{{ define "repo/fragments/shortTimeAgo" }}
+
{{ $formatted := shortRelTimeFmt . }}
+
{{ $content := printf "%s ago" $formatted }}
+
{{ if eq $formatted "now" }}
+
{{ $content = "now" }}
+
{{ end }}
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" $content) }}
{{ end }}
+3 -3
appview/pages/templates/repo/tree.html
···
<div class="flex flex-col md:flex-row md:justify-between gap-2">
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
{{ range .BreadCrumbs }}
-
<a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> /
{{ end }}
</div>
<div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
{{ $stats := .TreeStats }}
-
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span>
{{ if eq $stats.NumFolders 1 }}
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
<span>{{ $stats.NumFolders }} folder</span>
···
{{ range .Files }}
<div class="grid grid-cols-12 gap-4 items-center py-1">
<div class="col-span-8 md:col-span-4">
-
{{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }}
{{ $icon := "folder" }}
{{ $iconStyle := "size-4 fill-current" }}
···
<div class="flex flex-col md:flex-row md:justify-between gap-2">
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
{{ range .BreadCrumbs }}
+
<a href="{{ index . 1 }}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> /
{{ end }}
</div>
<div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
{{ $stats := .TreeStats }}
+
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ pathEscape $.Ref }}">{{ $.Ref }}</a></span>
{{ if eq $stats.NumFolders 1 }}
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
<span>{{ $stats.NumFolders }} folder</span>
···
{{ range .Files }}
<div class="grid grid-cols-12 gap-4 items-center py-1">
<div class="col-span-8 md:col-span-4">
+
{{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.Ref) $.TreePath .Name }}
{{ $icon := "folder" }}
{{ $iconStyle := "size-4 fill-current" }}
+3 -2
appview/pages/templates/strings/string.html
···
hx-boost="true"
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
{{ i "pencil" "size-4" }}
-
<span class="hidden md:inline">edit</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</a>
<button
···
hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?"
>
{{ i "trash-2" "size-4" }}
-
<span class="hidden md:inline">delete</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
</div>
···
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
{{ end }}
</div>
</section>
{{ end }}
···
hx-boost="true"
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
{{ i "pencil" "size-4" }}
+
<span class="hidden md:inline">edit</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</a>
<button
···
hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?"
>
{{ i "trash-2" "size-4" }}
+
<span class="hidden md:inline">delete</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
</div>
···
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
{{ end }}
</div>
+
{{ template "fragments/multiline-select" }}
</section>
{{ end }}
+28 -38
appview/pages/templates/timeline/fragments/timeline.html
···
{{ with $e }}
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
{{ if .Repo }}
-
{{ template "timeline/fragments/repoEvent" (list $ .) }}
{{ else if .Star }}
{{ template "timeline/fragments/starEvent" (list $ .Star) }}
{{ else if .Follow }}
-
{{ template "timeline/fragments/followEvent" (list $ .) }}
{{ end }}
</div>
{{ end }}
···
{{ define "timeline/fragments/repoEvent" }}
{{ $root := index . 0 }}
-
{{ $event := index . 1 }}
-
{{ $repo := $event.Repo }}
-
{{ $source := $event.Source }}
{{ $userHandle := resolve $repo.Did }}
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
{{ template "user/fragments/picHandleLink" $repo.Did }}
···
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
</div>
{{ with $repo }}
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
{{ end }}
{{ end }}
···
{{ define "timeline/fragments/followEvent" }}
{{ $root := index . 0 }}
-
{{ $event := index . 1 }}
-
{{ $follow := $event.Follow }}
-
{{ $profile := $event.Profile }}
-
{{ $stat := $event.FollowStats }}
{{ $userHandle := resolve $follow.UserDid }}
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
{{ template "user/fragments/picHandleLink" $subjectHandle }}
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
</div>
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4">
-
<div class="flex items-center gap-4 flex-1">
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
-
</div>
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
-
<a href="/{{ $subjectHandle }}">
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
-
</a>
-
{{ with $profile }}
-
{{ with .Description }}
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
-
{{ end }}
{{ end }}
-
{{ with $stat }}
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
-
<span class="select-none after:content-['ยท']"></span>
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
-
</div>
-
{{ end }}
-
</div>
</div>
-
-
{{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }}
-
<div class="flex-shrink-0 w-fit ml-auto">
-
{{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }}
-
</div>
-
{{ end }}
</div>
{{ end }}
···
{{ with $e }}
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
{{ if .Repo }}
+
{{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }}
{{ else if .Star }}
{{ template "timeline/fragments/starEvent" (list $ .Star) }}
{{ else if .Follow }}
+
{{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }}
{{ end }}
</div>
{{ end }}
···
{{ define "timeline/fragments/repoEvent" }}
{{ $root := index . 0 }}
+
{{ $repo := index . 1 }}
+
{{ $source := index . 2 }}
{{ $userHandle := resolve $repo.Did }}
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
{{ template "user/fragments/picHandleLink" $repo.Did }}
···
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
</div>
{{ with $repo }}
+
{{ template "user/fragments/repoCard" (list $root . true) }}
{{ end }}
{{ end }}
···
{{ define "timeline/fragments/followEvent" }}
{{ $root := index . 0 }}
+
{{ $follow := index . 1 }}
+
{{ $profile := index . 2 }}
+
{{ $stat := index . 3 }}
{{ $userHandle := resolve $follow.UserDid }}
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
{{ template "user/fragments/picHandleLink" $subjectHandle }}
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
</div>
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
+
<div class="flex-shrink-0 max-h-full w-24 h-24">
+
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
+
</div>
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
+
<a href="/{{ $subjectHandle }}">
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
+
</a>
+
{{ with $profile }}
+
{{ with .Description }}
+
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
{{ end }}
+
{{ end }}
+
{{ with $stat }}
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
+
<span class="select-none after:content-['ยท']"></span>
+
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
+
</div>
+
{{ end }}
</div>
</div>
{{ end }}
+2 -2
appview/pages/templates/user/fragments/follow.html
···
{{ define "user/fragments/follow" }}
<button id="{{ normalizeForHtmlId .UserDid }}"
-
class="btn mt-2 flex gap-2 items-center group"
{{ if eq .FollowStatus.String "IsNotFollowing" }}
hx-post="/follow?subject={{.UserDid}}"
···
hx-target="#{{ normalizeForHtmlId .UserDid }}"
hx-swap="outerHTML"
>
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }}
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
{{ end }}
···
{{ define "user/fragments/follow" }}
<button id="{{ normalizeForHtmlId .UserDid }}"
+
class="btn mt-2 w-full flex gap-2 items-center group"
{{ if eq .FollowStatus.String "IsNotFollowing" }}
hx-post="/follow?subject={{.UserDid}}"
···
hx-target="#{{ normalizeForHtmlId .UserDid }}"
hx-swap="outerHTML"
>
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
{{ end }}
+7 -21
appview/pages/templates/user/fragments/repoCard.html
···
{{ $root := index . 0 }}
{{ $repo := index . 1 }}
{{ $fullName := index . 2 }}
-
{{ $starButton := false }}
-
{{ $starData := dict }}
-
{{ if gt (len .) 3 }}
-
{{ $starButton = index . 3 }}
-
{{ if gt (len .) 4 }}
-
{{ $starData = index . 4 }}
-
{{ end }}
-
{{ end }}
{{ with $repo }}
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
-
<div class="font-medium dark:text-white flex items-center justify-between">
-
<div class="flex items-center">
{{ if .Source }}
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
{{ else }}
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
{{ end }}
-
{{ $repoOwner := resolve .Did }}
-
{{- if $fullName -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
-
{{- else -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
-
{{- end -}}
-
</div>
-
-
{{ if and $starButton $root.LoggedInUser }}
-
{{ template "repo/fragments/repoStar" $starData }}
-
{{ end }}
</div>
{{ with .Description }}
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
···
{{ $root := index . 0 }}
{{ $repo := index . 1 }}
{{ $fullName := index . 2 }}
{{ with $repo }}
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
+
<div class="font-medium dark:text-white flex items-center">
{{ if .Source }}
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
{{ else }}
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
{{ end }}
+
{{ $repoOwner := resolve .Did }}
+
{{- if $fullName -}}
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
+
{{- else -}}
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
+
{{- end -}}
</div>
{{ with .Description }}
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
+33
appview/posthog/notifier.go
···
log.Println("failed to enqueue posthog event:", err)
}
}
···
log.Println("failed to enqueue posthog event:", err)
}
}
+
+
func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: did,
+
Event: "delete_string",
+
Properties: posthog.Properties{"rkey": rkey},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) EditString(ctx context.Context, string *db.String) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: string.Did.String(),
+
Event: "edit_string",
+
Properties: posthog.Properties{"rkey": string.Rkey},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) CreateString(ctx context.Context, string *db.String) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: string.Did.String(),
+
Event: "create_string",
+
Properties: posthog.Properties{"rkey": string.Rkey},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+16 -17
appview/repo/index.go
···
"fmt"
"log"
"net/http"
"slices"
"sort"
"strings"
···
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
ref := chi.URLParam(r, "ref")
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
RepoInfo: repoInfo,
})
return
-
} else {
-
rp.pages.Error503(w)
-
log.Println("failed to build index response", err)
-
return
}
}
tagMap := make(map[string][]string)
···
// first get branches to determine the ref if not specified
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
if err != nil {
-
return nil, err
}
var branchesResp types.RepoBranchesResponse
if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
-
return nil, err
}
// if no ref specified, use default branch or first available
-
if ref == "" && len(branchesResp.Branches) > 0 {
for _, branch := range branchesResp.Branches {
if branch.IsDefault {
ref = branch.Name
break
}
}
-
if ref == "" {
-
ref = branchesResp.Branches[0].Name
-
}
}
-
// check if repo is empty
-
if len(branchesResp.Branches) == 0 {
return &types.RepoIndexResponse{
IsEmpty: true,
Branches: branchesResp.Branches,
···
defer wg.Done()
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
if err != nil {
-
errs = errors.Join(errs, err)
return
}
if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
-
errs = errors.Join(errs, err)
}
}()
···
defer wg.Done()
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
if err != nil {
-
errs = errors.Join(errs, err)
return
}
treeResp = resp
···
defer wg.Done()
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
if err != nil {
-
errs = errors.Join(errs, err)
return
}
if err := json.Unmarshal(logBytes, &logResp); err != nil {
-
errs = errors.Join(errs, err)
}
}()
···
"fmt"
"log"
"net/http"
+
"net/url"
"slices"
"sort"
"strings"
···
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
RepoInfo: repoInfo,
})
return
}
+
+
rp.pages.Error503(w)
+
log.Println("failed to build index response", err)
+
return
}
tagMap := make(map[string][]string)
···
// first get branches to determine the ref if not specified
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
if err != nil {
+
return nil, fmt.Errorf("failed to call repoBranches: %w", err)
}
var branchesResp types.RepoBranchesResponse
if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal branches response: %w", err)
}
// if no ref specified, use default branch or first available
+
if ref == "" {
for _, branch := range branchesResp.Branches {
if branch.IsDefault {
ref = branch.Name
break
}
}
}
+
// if ref is still empty, this means the default branch is not set
+
if ref == "" {
return &types.RepoIndexResponse{
IsEmpty: true,
Branches: branchesResp.Branches,
···
defer wg.Done()
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
if err != nil {
+
errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err))
return
}
if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
+
errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoTags: %w", err))
}
}()
···
defer wg.Done()
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
if err != nil {
+
errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err))
return
}
treeResp = resp
···
defer wg.Done()
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
if err != nil {
+
errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err))
return
}
if err := json.Unmarshal(logBytes, &logResp); err != nil {
+
errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoLog: %w", err))
}
}()
+97 -118
appview/repo/repo.go
···
}
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
-
refParam := chi.URLParam(r, "ref")
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
···
}
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", refParam, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.archive", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Error404(w)
return
}
-
// Set headers for file download
-
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, refParam)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
···
}
ref := chi.URLParam(r, "ref")
scheme := "http"
if !rp.config.Core.Dev {
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.log", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Error404(w)
return
}
···
}
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
}
tagMap := make(map[string][]string)
···
}
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
}
if branchBytes != nil {
···
return
}
ref := chi.URLParam(r, "ref")
var diffOpts types.DiffOpts
if d := r.URL.Query().Get("diff"); d == "split" {
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.diff", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Error404(w)
return
}
···
}
ref := chi.URLParam(r, "ref")
-
treePath := chi.URLParam(r, "*")
// if the tree path has a trailing slash, let's strip it
// so we don't 404
treePath = strings.TrimSuffix(treePath, "/")
scheme := "http"
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.tree", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Error404(w)
return
}
···
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
// so we can safely redirect to the "parent" (which is the same file).
-
unescapedTreePath, _ := url.PathUnescape(treePath)
-
if len(result.Files) == 0 && result.Parent == unescapedTreePath {
-
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
return
}
user := rp.oauth.GetUser(r)
var breadcrumbs [][]string
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
if treePath != "" {
for idx, elem := range strings.Split(treePath, "/") {
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
}
}
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Error404(w)
return
}
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Error404(w)
return
}
···
}
ref := chi.URLParam(r, "ref")
filePath := chi.URLParam(r, "*")
scheme := "http"
if !rp.config.Core.Dev {
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.blob", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Error404(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(), ref)})
if filePath != "" {
for idx, elem := range strings.Split(filePath, "/") {
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
}
}
···
// fetch the raw binary content using sh.tangled.repo.blob xrpc
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
-
scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath))
contentSrc = blobURL
if !rp.config.Core.Dev {
···
}
ref := chi.URLParam(r, "ref")
filePath := chi.URLParam(r, "*")
scheme := "http"
if !rp.config.Core.Dev {
···
}
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
-
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
-
scheme, f.Knot, url.QueryEscape(repo), url.QueryEscape(ref), url.QueryEscape(filePath))
req, err := http.NewRequest("GET", blobURL, nil)
if err != nil {
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
rp.pages.Error503(w)
return
}
···
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
ref := chi.URLParam(r, "ref")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
}
···
}
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
}
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
}
···
}
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
}
···
}
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
-
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.compare", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
}
···
}
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
+
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
···
}
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.archive", xrpcerr)
+
rp.pages.Error503(w)
return
}
+
// Set headers for file download, just pass along whatever the knot specifies
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
···
}
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
scheme := "http"
if !rp.config.Core.Dev {
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.log", xrpcerr)
+
rp.pages.Error503(w)
return
}
···
}
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
+
return
}
tagMap := make(map[string][]string)
···
}
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
+
return
}
if branchBytes != nil {
···
return
}
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
var diffOpts types.DiffOpts
if d := r.URL.Query().Get("diff"); d == "split" {
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.diff", xrpcerr)
+
rp.pages.Error503(w)
return
}
···
}
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
// if the tree path has a trailing slash, let's strip it
// so we don't 404
+
treePath := chi.URLParam(r, "*")
+
treePath, _ = url.PathUnescape(treePath)
treePath = strings.TrimSuffix(treePath, "/")
scheme := "http"
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tree", xrpcerr)
+
rp.pages.Error503(w)
return
}
···
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
// so we can safely redirect to the "parent" (which is the same file).
+
if len(result.Files) == 0 && result.Parent == treePath {
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
+
http.Redirect(w, r, redirectTo, http.StatusFound)
return
}
user := rp.oauth.GetUser(r)
var breadcrumbs [][]string
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
if treePath != "" {
for idx, elem := range strings.Split(treePath, "/") {
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
}
}
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
return
}
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
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 {
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.blob", xrpcerr)
+
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))})
if filePath != "" {
for idx, elem := range strings.Split(filePath, "/") {
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
}
}
···
// 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 {
···
}
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
filePath := chi.URLParam(r, "*")
+
filePath, _ = url.PathUnescape(filePath)
scheme := "http"
if !rp.config.Core.Dev {
···
}
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
+
baseURL := &url.URL{
+
Scheme: scheme,
+
Host: f.Knot,
+
Path: "/xrpc/sh.tangled.repo.blob",
+
}
+
query := baseURL.Query()
+
query.Set("repo", repo)
+
query.Set("ref", ref)
+
query.Set("path", filePath)
+
query.Set("raw", "true")
+
baseURL.RawQuery = query.Encode()
+
blobURL := baseURL.String()
req, err := http.NewRequest("GET", blobURL, nil)
if err != nil {
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
rp.pages.Error503(w)
return
}
···
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
return
}
···
}
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
return
}
···
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
return
}
···
}
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
return
}
···
}
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
+
rp.pages.Error503(w)
return
}
+2 -6
appview/state/state.go
···
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
-
var userDid string
-
if user != nil {
-
userDid = user.Did
-
}
-
timeline, err := db.MakeTimeline(s.db, 50, userDid)
if err != nil {
log.Println(err)
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
}
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
-
timeline, err := db.MakeTimeline(s.db, 5, "")
if err != nil {
log.Println(err)
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
+
timeline, err := db.MakeTimeline(s.db, 50)
if err != nil {
log.Println(err)
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
}
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
+
timeline, err := db.MakeTimeline(s.db, 5)
if err != nil {
log.Println(err)
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
+8
appview/strings/strings.go
···
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/middleware"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/pages/markup"
···
IdResolver *idresolver.Resolver
Logger *slog.Logger
Knotstream *eventconsumer.Consumer
}
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
···
return
}
// if that went okay, redir to the string
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
}
···
return
}
// successful
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
}
···
fail("Failed to delete string.", err)
return
}
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
}
···
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/middleware"
+
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/pages/markup"
···
IdResolver *idresolver.Resolver
Logger *slog.Logger
Knotstream *eventconsumer.Consumer
+
Notifier notify.Notifier
}
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
···
return
}
+
s.Notifier.EditString(r.Context(), &entry)
+
// if that went okay, redir to the string
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
}
···
return
}
+
s.Notifier.NewString(r.Context(), &string)
+
// successful
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
}
···
fail("Failed to delete string.", err)
return
}
+
+
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
}
+44
contrib/Tiltfile
···
···
+
common_env = {
+
"TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""),
+
"TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""),
+
"TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"),
+
"TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"),
+
}
+
+
nix_globs = ["nix/**", "flake.nix", "flake.lock"]
+
+
local_resource(
+
name="appview",
+
serve_cmd="nix run .#watch-appview",
+
serve_dir="..",
+
deps=nix_globs,
+
env=common_env,
+
allow_parallel=True,
+
)
+
+
local_resource(
+
name="tailwind",
+
serve_cmd="nix run .#watch-tailwind",
+
serve_dir="..",
+
deps=nix_globs,
+
env=common_env,
+
allow_parallel=True,
+
)
+
+
local_resource(
+
name="redis",
+
serve_cmd="redis-server",
+
serve_dir="..",
+
deps=nix_globs,
+
env=common_env,
+
allow_parallel=True,
+
)
+
+
local_resource(
+
name="vm",
+
serve_cmd="nix run --impure .#vm",
+
serve_dir="..",
+
deps=nix_globs,
+
env=common_env,
+
allow_parallel=True,
+
)
+16
default.nix
···
···
+
# Default setup from https://git.lix.systems/lix-project/flake-compat
+
let
+
lockFile = builtins.fromJSON (builtins.readFile ./flake.lock);
+
flake-compat-node = lockFile.nodes.${lockFile.nodes.root.inputs.flake-compat};
+
flake-compat = builtins.fetchTarball {
+
inherit (flake-compat-node.locked) url;
+
sha256 = flake-compat-node.locked.narHash;
+
};
+
+
flake = (
+
import flake-compat {
+
src = ./.;
+
}
+
);
+
in
+
flake.defaultNix
+15
flake.lock
···
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
···
},
"root": {
"inputs": {
"gomod2nix": "gomod2nix",
"htmx-src": "htmx-src",
"htmx-ws-src": "htmx-ws-src",
···
{
"nodes": {
+
"flake-compat": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1751685974,
+
"narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=",
+
"rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1",
+
"type": "tarball",
+
"url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz?rev=549f2762aebeff29a2e5ece7a7dc0f955281a1d1"
+
},
+
"original": {
+
"type": "tarball",
+
"url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz"
+
}
+
},
"flake-utils": {
"inputs": {
"systems": "systems"
···
},
"root": {
"inputs": {
+
"flake-compat": "flake-compat",
"gomod2nix": "gomod2nix",
"htmx-src": "htmx-src",
"htmx-ws-src": "htmx-ws-src",
+7 -1
flake.nix
···
url = "github:nix-community/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
indigo = {
url = "github:oppiliappan/indigo";
flake = false;
···
inter-fonts-src,
sqlite-lib-src,
ibm-plex-mono-src,
}: let
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
···
nativeBuildInputs = [
pkgs.go
pkgs.air
pkgs.gopls
pkgs.httpie
pkgs.litecli
···
tailwind-watcher =
pkgs.writeShellScriptBin "run"
''
-
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
'';
in {
fmt = {
···
url = "github:nix-community/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
+
flake-compat = {
+
url = "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz";
+
flake = false;
+
};
indigo = {
url = "github:oppiliappan/indigo";
flake = false;
···
inter-fonts-src,
sqlite-lib-src,
ibm-plex-mono-src,
+
...
}: let
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
···
nativeBuildInputs = [
pkgs.go
pkgs.air
+
pkgs.tilt
pkgs.gopls
pkgs.httpie
pkgs.litecli
···
tailwind-watcher =
pkgs.writeShellScriptBin "run"
''
+
${pkgs.tailwindcss}/bin/tailwindcss --watch=always -i input.css -o ./appview/pages/static/tw.css
'';
in {
fmt = {
+2 -5
input.css
···
}
/* LineHighlight */
.chroma .hl {
-
background-color: #bcc0cc;
}
/* LineNumbersTable */
.chroma .lnt {
white-space: pre;
···
text-decoration: underline;
}
}
-
-
.chroma .line:has(.ln:target) {
-
@apply bg-amber-400/30 dark:bg-amber-500/20;
-
}
···
}
/* LineHighlight */
.chroma .hl {
+
@apply bg-amber-400/30 dark:bg-amber-500/20;
}
+
/* LineNumbersTable */
.chroma .lnt {
white-space: pre;
···
text-decoration: underline;
}
}
+4 -1
knotserver/git/language.go
···
import (
"context"
"path"
"github.com/go-enry/go-enry/v2"
"github.com/go-git/go-git/v5/plumbing/object"
···
return nil
}
-
if enry.IsGenerated(filepath, content) {
return nil
}
···
import (
"context"
"path"
+
"strings"
"github.com/go-enry/go-enry/v2"
"github.com/go-git/go-git/v5/plumbing/object"
···
return nil
}
+
if enry.IsGenerated(filepath, content) ||
+
enry.IsBinary(content) ||
+
strings.HasSuffix(filepath, "bun.lock") {
return nil
}
+1 -10
knotserver/xrpc/list_keys.go
···
package xrpc
import (
-
"encoding/json"
"net/http"
"strconv"
···
response.Cursor = &nextCursor
}
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"net/http"
"strconv"
···
response.Cursor = &nextCursor
}
+
writeJson(w, response)
}
+1 -10
knotserver/xrpc/owner.go
···
package xrpc
import (
-
"encoding/json"
"net/http"
"tangled.sh/tangled.sh/core/api/tangled"
···
Owner: owner,
}
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"net/http"
"tangled.sh/tangled.sh/core/api/tangled"
···
Owner: owner,
}
+
writeJson(w, response)
}
+8 -7
knotserver/xrpc/repo_archive.go
···
)
func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
-
repo, repoPath, unescapedRef, err := x.parseStandardParams(r)
if err != nil {
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
return
}
format := r.URL.Query().Get("format")
if format == "" {
···
return
}
-
gr, err := git.Open(repoPath, unescapedRef)
if err != nil {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RefNotFound"),
-
xrpcerr.WithMessage("repository or ref not found"),
-
), http.StatusNotFound)
return
}
repoParts := strings.Split(repo, "/")
repoName := repoParts[len(repoParts)-1]
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
var archivePrefix string
if prefix != "" {
···
)
func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
if err != nil {
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
return
}
+
+
ref := r.URL.Query().Get("ref")
+
// ref can be empty (git.Open handles this)
format := r.URL.Query().Get("format")
if format == "" {
···
return
}
+
gr, err := git.Open(repoPath, ref)
if err != nil {
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
return
}
repoParts := strings.Split(repo, "/")
repoName := repoParts[len(repoParts)-1]
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
var archivePrefix string
if prefix != "" {
+7 -15
knotserver/xrpc/repo_blob.go
···
import (
"crypto/sha256"
"encoding/base64"
-
"encoding/json"
"fmt"
"net/http"
"path/filepath"
···
)
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
-
_, repoPath, ref, err := x.parseStandardParams(r)
if err != nil {
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
return
}
treePath := r.URL.Query().Get("path")
if treePath == "" {
writeError(w, xrpcerr.NewXrpcError(
···
gr, err := git.Open(repoPath, ref)
if err != nil {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RefNotFound"),
-
xrpcerr.WithMessage("repository or ref not found"),
-
), http.StatusNotFound)
return
}
···
response.MimeType = &mimeType
}
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
// isTextualMimeType returns true if the MIME type represents textual content
···
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"path/filepath"
···
)
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
if err != nil {
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
return
}
+
ref := r.URL.Query().Get("ref")
+
// ref can be empty (git.Open handles this)
+
treePath := r.URL.Query().Get("path")
if treePath == "" {
writeError(w, xrpcerr.NewXrpcError(
···
gr, err := git.Open(repoPath, ref)
if err != nil {
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
return
}
···
response.MimeType = &mimeType
}
+
writeJson(w, response)
}
// isTextualMimeType returns true if the MIME type represents textual content
+5 -16
knotserver/xrpc/repo_branch.go
···
package xrpc
import (
-
"encoding/json"
"net/http"
"net/url"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/knotserver/git"
···
gr, err := git.PlainOpen(repoPath)
if err != nil {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RepoNotFound"),
-
xrpcerr.WithMessage("repository not found"),
-
), http.StatusNotFound)
return
}
···
Name: ref.Name().Short(),
Hash: ref.Hash().String(),
ShortHash: &[]string{ref.Hash().String()[:7]}[0],
-
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
IsDefault: &isDefault,
}
···
response.Author = &tangled.RepoBranch_Signature{
Name: commit.Author.Name,
Email: commit.Author.Email,
-
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
}
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"net/http"
"net/url"
+
"time"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/knotserver/git"
···
gr, err := git.PlainOpen(repoPath)
if err != nil {
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent)
return
}
···
Name: ref.Name().Short(),
Hash: ref.Hash().String(),
ShortHash: &[]string{ref.Hash().String()[:7]}[0],
+
When: commit.Author.When.Format(time.RFC3339),
IsDefault: &isDefault,
}
···
response.Author = &tangled.RepoBranch_Signature{
Name: commit.Author.Name,
Email: commit.Author.Email,
+
When: commit.Author.When.Format(time.RFC3339),
}
+
writeJson(w, response)
}
+3 -19
knotserver/xrpc/repo_branches.go
···
package xrpc
import (
-
"encoding/json"
"net/http"
"strconv"
···
gr, err := git.PlainOpen(repoPath)
if err != nil {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RepoNotFound"),
-
xrpcerr.WithMessage("repository not found"),
-
), http.StatusNotFound)
return
}
···
}
}
-
end := offset + limit
-
if end > len(branches) {
-
end = len(branches)
-
}
paginatedBranches := branches[offset:end]
···
Branches: paginatedBranches,
}
-
// Write JSON response directly
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"net/http"
"strconv"
···
gr, err := git.PlainOpen(repoPath)
if err != nil {
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent)
return
}
···
}
}
+
end := min(offset+limit, len(branches))
paginatedBranches := branches[offset:end]
···
Branches: paginatedBranches,
}
+
writeJson(w, response)
}
+7 -23
knotserver/xrpc/repo_compare.go
···
package xrpc
import (
-
"encoding/json"
"fmt"
"net/http"
-
"net/url"
"tangled.sh/tangled.sh/core/knotserver/git"
"tangled.sh/tangled.sh/core/types"
···
return
}
-
rev1Param := r.URL.Query().Get("rev1")
-
if rev1Param == "" {
writeError(w, xrpcerr.NewXrpcError(
xrpcerr.WithTag("InvalidRequest"),
xrpcerr.WithMessage("missing rev1 parameter"),
···
return
}
-
rev2Param := r.URL.Query().Get("rev2")
-
if rev2Param == "" {
writeError(w, xrpcerr.NewXrpcError(
xrpcerr.WithTag("InvalidRequest"),
xrpcerr.WithMessage("missing rev2 parameter"),
···
return
}
-
rev1, _ := url.PathUnescape(rev1Param)
-
rev2, _ := url.PathUnescape(rev2Param)
-
gr, err := git.PlainOpen(repoPath)
if err != nil {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RepoNotFound"),
-
xrpcerr.WithMessage("repository not found"),
-
), http.StatusNotFound)
return
}
···
return
}
-
resp := types.RepoFormatPatchResponse{
Rev1: commit1.Hash.String(),
Rev2: commit2.Hash.String(),
FormatPatch: formatPatch,
Patch: rawPatch,
}
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(resp); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"fmt"
"net/http"
"tangled.sh/tangled.sh/core/knotserver/git"
"tangled.sh/tangled.sh/core/types"
···
return
}
+
rev1 := r.URL.Query().Get("rev1")
+
if rev1 == "" {
writeError(w, xrpcerr.NewXrpcError(
xrpcerr.WithTag("InvalidRequest"),
xrpcerr.WithMessage("missing rev1 parameter"),
···
return
}
+
rev2 := r.URL.Query().Get("rev2")
+
if rev2 == "" {
writeError(w, xrpcerr.NewXrpcError(
xrpcerr.WithTag("InvalidRequest"),
xrpcerr.WithMessage("missing rev2 parameter"),
···
return
}
gr, err := git.PlainOpen(repoPath)
if err != nil {
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent)
return
}
···
return
}
+
response := types.RepoFormatPatchResponse{
Rev1: commit1.Hash.String(),
Rev2: commit2.Hash.String(),
FormatPatch: formatPatch,
Patch: rawPatch,
}
+
writeJson(w, response)
}
+6 -30
knotserver/xrpc/repo_diff.go
···
package xrpc
import (
-
"encoding/json"
"net/http"
-
"net/url"
"tangled.sh/tangled.sh/core/knotserver/git"
"tangled.sh/tangled.sh/core/types"
···
return
}
-
refParam := r.URL.Query().Get("ref")
-
if refParam == "" {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InvalidRequest"),
-
xrpcerr.WithMessage("missing ref parameter"),
-
), http.StatusBadRequest)
-
return
-
}
-
-
ref, _ := url.QueryUnescape(refParam)
gr, err := git.Open(repoPath, ref)
if err != nil {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RefNotFound"),
-
xrpcerr.WithMessage("repository or ref not found"),
-
), http.StatusNotFound)
return
}
diff, err := gr.Diff()
if err != nil {
x.Logger.Error("getting diff", "error", err.Error())
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RefNotFound"),
-
xrpcerr.WithMessage("failed to generate diff"),
-
), http.StatusInternalServerError)
return
}
-
resp := types.RepoCommitResponse{
Ref: ref,
Diff: diff,
}
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(resp); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"net/http"
"tangled.sh/tangled.sh/core/knotserver/git"
"tangled.sh/tangled.sh/core/types"
···
return
}
+
ref := r.URL.Query().Get("ref")
+
// ref can be empty (git.Open handles this)
gr, err := git.Open(repoPath, ref)
if err != nil {
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
return
}
diff, err := gr.Diff()
if err != nil {
x.Logger.Error("getting diff", "error", err.Error())
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusInternalServerError)
return
}
+
response := types.RepoCommitResponse{
Ref: ref,
Diff: diff,
}
+
writeJson(w, response)
}
+4 -19
knotserver/xrpc/repo_get_default_branch.go
···
package xrpc
import (
-
"encoding/json"
"net/http"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/knotserver/git"
···
return
}
-
gr, err := git.Open(repoPath, "")
-
if err != nil {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RepoNotFound"),
-
xrpcerr.WithMessage("repository not found"),
-
), http.StatusNotFound)
-
return
-
}
branch, err := gr.FindMainBranch()
if err != nil {
···
response := tangled.RepoGetDefaultBranch_Output{
Name: branch,
Hash: "",
-
When: "1970-01-01T00:00:00.000Z",
}
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"net/http"
+
"time"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/knotserver/git"
···
return
}
+
gr, err := git.PlainOpen(repoPath)
branch, err := gr.FindMainBranch()
if err != nil {
···
response := tangled.RepoGetDefaultBranch_Output{
Name: branch,
Hash: "",
+
When: time.UnixMicro(0).Format(time.RFC3339),
}
+
writeJson(w, response)
}
+4 -21
knotserver/xrpc/repo_languages.go
···
import (
"context"
-
"encoding/json"
"math"
"net/http"
-
"net/url"
"time"
"tangled.sh/tangled.sh/core/api/tangled"
···
)
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
-
refParam := r.URL.Query().Get("ref")
-
if refParam == "" {
-
refParam = "HEAD" // default
-
}
-
ref, _ := url.PathUnescape(refParam)
-
repo := r.URL.Query().Get("repo")
repoPath, err := x.parseRepoParam(repo)
if err != nil {
···
return
}
gr, err := git.Open(repoPath, ref)
if err != nil {
x.Logger.Error("opening repo", "error", err.Error())
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RefNotFound"),
-
xrpcerr.WithMessage("repository or ref not found"),
-
), http.StatusNotFound)
return
}
···
response.TotalFiles = &totalFiles
}
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
import (
"context"
"math"
"net/http"
"time"
"tangled.sh/tangled.sh/core/api/tangled"
···
)
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
repo := r.URL.Query().Get("repo")
repoPath, err := x.parseRepoParam(repo)
if err != nil {
···
return
}
+
ref := r.URL.Query().Get("ref")
+
gr, err := git.Open(repoPath, ref)
if err != nil {
x.Logger.Error("opening repo", "error", err.Error())
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
return
}
···
response.TotalFiles = &totalFiles
}
+
writeJson(w, response)
}
+3 -33
knotserver/xrpc/repo_log.go
···
package xrpc
import (
-
"encoding/json"
"net/http"
-
"net/url"
"strconv"
"tangled.sh/tangled.sh/core/knotserver/git"
···
return
}
-
refParam := r.URL.Query().Get("ref")
-
if refParam == "" {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InvalidRequest"),
-
xrpcerr.WithMessage("missing ref parameter"),
-
), http.StatusBadRequest)
-
return
-
}
path := r.URL.Query().Get("path")
cursor := r.URL.Query().Get("cursor")
···
}
}
-
ref, err := url.QueryUnescape(refParam)
-
if err != nil {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InvalidRequest"),
-
xrpcerr.WithMessage("invalid ref parameter"),
-
), http.StatusBadRequest)
-
return
-
}
-
gr, err := git.Open(repoPath, ref)
if err != nil {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RefNotFound"),
-
xrpcerr.WithMessage("repository or ref not found"),
-
), http.StatusNotFound)
return
}
···
response.Log = true
-
// Write JSON response directly
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"net/http"
"strconv"
"tangled.sh/tangled.sh/core/knotserver/git"
···
return
}
+
ref := r.URL.Query().Get("ref")
path := r.URL.Query().Get("path")
cursor := r.URL.Query().Get("cursor")
···
}
}
gr, err := git.Open(repoPath, ref)
if err != nil {
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
return
}
···
response.Log = true
+
writeJson(w, response)
}
+3 -16
knotserver/xrpc/repo_tags.go
···
package xrpc
import (
-
"encoding/json"
"net/http"
"strconv"
···
}
}
-
gr, err := git.Open(repoPath, "")
if err != nil {
x.Logger.Error("failed to open", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RepoNotFound"),
-
xrpcerr.WithMessage("repository not found"),
-
), http.StatusNotFound)
return
}
···
Tags: paginatedTags,
}
-
// Write JSON response directly
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"net/http"
"strconv"
···
}
}
+
gr, err := git.PlainOpen(repoPath)
if err != nil {
x.Logger.Error("failed to open", "error", err)
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent)
return
}
···
Tags: paginatedTags,
}
+
writeJson(w, response)
}
+6 -33
knotserver/xrpc/repo_tree.go
···
package xrpc
import (
-
"encoding/json"
"net/http"
-
"net/url"
"path/filepath"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/knotserver/git"
···
return
}
-
refParam := r.URL.Query().Get("ref")
-
if refParam == "" {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InvalidRequest"),
-
xrpcerr.WithMessage("missing ref parameter"),
-
), http.StatusBadRequest)
-
return
-
}
path := r.URL.Query().Get("path")
// path can be empty (defaults to root)
-
ref, err := url.QueryUnescape(refParam)
-
if err != nil {
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InvalidRequest"),
-
xrpcerr.WithMessage("invalid ref parameter"),
-
), http.StatusBadRequest)
-
return
-
}
-
gr, err := git.Open(repoPath, ref)
if err != nil {
x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RefNotFound"),
-
xrpcerr.WithMessage("repository or ref not found"),
-
), http.StatusNotFound)
return
}
···
entry.Last_commit = &tangled.RepoTree_LastCommit{
Hash: file.LastCommit.Hash.String(),
Message: file.LastCommit.Message,
-
When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"),
}
}
···
Files: treeEntries,
}
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"net/http"
"path/filepath"
+
"time"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/knotserver/git"
···
return
}
+
ref := r.URL.Query().Get("ref")
+
// ref can be empty (git.Open handles this)
path := r.URL.Query().Get("path")
// path can be empty (defaults to root)
gr, err := git.Open(repoPath, ref)
if err != nil {
x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref)
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
return
}
···
entry.Last_commit = &tangled.RepoTree_LastCommit{
Hash: file.LastCommit.Hash.String(),
Message: file.LastCommit.Message,
+
When: file.LastCommit.When.Format(time.RFC3339),
}
}
···
Files: treeEntries,
}
+
writeJson(w, response)
}
+1 -11
knotserver/xrpc/version.go
···
package xrpc
import (
-
"encoding/json"
"fmt"
"net/http"
"runtime/debug"
"tangled.sh/tangled.sh/core/api/tangled"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
// version is set during build time.
···
Version: version,
}
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(response); err != nil {
-
x.Logger.Error("failed to encode response", "error", err)
-
writeError(w, xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InternalServerError"),
-
xrpcerr.WithMessage("failed to encode response"),
-
), http.StatusInternalServerError)
-
return
-
}
}
···
package xrpc
import (
"fmt"
"net/http"
"runtime/debug"
"tangled.sh/tangled.sh/core/api/tangled"
)
// version is set during build time.
···
Version: version,
}
+
writeJson(w, response)
}
+14 -35
knotserver/xrpc/xrpc.go
···
"encoding/json"
"log/slog"
"net/http"
-
"net/url"
"strings"
securejoin "github.com/cyphar/filepath-securejoin"
···
}
// Parse repo string (did/repoName format)
-
parts := strings.Split(repo, "/")
-
if len(parts) < 2 {
return "", xrpcerr.NewXrpcError(
xrpcerr.WithTag("InvalidRequest"),
xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
)
}
-
did := strings.Join(parts[:len(parts)-1], "/")
-
repoName := parts[len(parts)-1]
// Construct repository path using the same logic as didPath
didRepoPath, err := securejoin.SecureJoin(did, repoName)
if err != nil {
-
return "", xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RepoNotFound"),
-
xrpcerr.WithMessage("failed to access repository"),
-
)
}
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
if err != nil {
-
return "", xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("RepoNotFound"),
-
xrpcerr.WithMessage("failed to access repository"),
-
)
}
return repoPath, nil
}
-
// parseStandardParams parses common query parameters used by most handlers
-
func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) {
-
// Parse repo parameter
-
repo = r.URL.Query().Get("repo")
-
repoPath, err = x.parseRepoParam(repo)
-
if err != nil {
-
return "", "", "", err
-
}
-
-
// Parse and unescape ref parameter
-
refParam := r.URL.Query().Get("ref")
-
if refParam == "" {
-
return "", "", "", xrpcerr.NewXrpcError(
-
xrpcerr.WithTag("InvalidRequest"),
-
xrpcerr.WithMessage("missing ref parameter"),
-
)
-
}
-
-
ref, _ = url.QueryUnescape(refParam)
-
return repo, repoPath, ref, nil
-
}
-
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(e)
}
···
"encoding/json"
"log/slog"
"net/http"
"strings"
securejoin "github.com/cyphar/filepath-securejoin"
···
}
// Parse repo string (did/repoName format)
+
parts := strings.SplitN(repo, "/", 2)
+
if len(parts) != 2 {
return "", xrpcerr.NewXrpcError(
xrpcerr.WithTag("InvalidRequest"),
xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
)
}
+
did := parts[0]
+
repoName := parts[1]
// Construct repository path using the same logic as didPath
didRepoPath, err := securejoin.SecureJoin(did, repoName)
if err != nil {
+
return "", xrpcerr.RepoNotFoundError
}
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
if err != nil {
+
return "", xrpcerr.RepoNotFoundError
}
return repoPath, nil
}
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(e)
}
+
+
func writeJson(w http.ResponseWriter, response any) {
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
}
+10
xrpc/errors/errors.go
···
WithMessage("owner not set for this service"),
)
var AuthError = func(err error) XrpcError {
return NewXrpcError(
WithTag("Auth"),
···
WithMessage("owner not set for this service"),
)
+
var RepoNotFoundError = NewXrpcError(
+
WithTag("RepoNotFound"),
+
WithMessage("failed to access repository"),
+
)
+
+
var RefNotFoundError = NewXrpcError(
+
WithTag("RefNotFound"),
+
WithMessage("failed to access ref"),
+
)
+
var AuthError = func(err error) XrpcError {
return NewXrpcError(
WithTag("Auth"),