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

Compare changes

Choose any two refs to compare.

+35 -20
appview/db/issues.go
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.sh/tangled.sh/core/appview/pagination"
)
type Issue struct {
···
return ownerDid, err
}
-
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool) ([]Issue, error) {
+
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
var issues []Issue
openValue := 0
if isOpen {
···
}
rows, err := e.Query(
-
`select
-
i.owner_did,
-
i.issue_id,
-
i.created,
-
i.title,
-
i.body,
-
i.open,
-
count(c.id)
-
from
-
issues i
-
left join
-
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
-
where
-
i.repo_at = ? and i.open = ?
-
group by
-
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
-
order by
-
i.created desc`,
-
repoAt, openValue)
+
`
+
with numbered_issue as (
+
select
+
i.owner_did,
+
i.issue_id,
+
i.created,
+
i.title,
+
i.body,
+
i.open,
+
count(c.id) as comment_count,
+
row_number() over (order by i.created desc) as row_num
+
from
+
issues i
+
left join
+
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
+
where
+
i.repo_at = ? and i.open = ?
+
group by
+
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
+
)
+
select
+
owner_did,
+
issue_id,
+
created,
+
title,
+
body,
+
open,
+
comment_count
+
from
+
numbered_issue
+
where
+
row_num between ? and ?`,
+
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
if err != nil {
return nil, err
}
+12
appview/db/pulls.go
···
return nil, err
}
+
var pullSourceRepo *Repo
+
if pull.PullSource != nil {
+
if pull.PullSource.RepoAt != nil {
+
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
+
if err != nil {
+
log.Printf("failed to get repo by at uri: %v", err)
+
} else {
+
pull.PullSource.Repo = pullSourceRepo
+
}
+
}
+
}
+
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
for _, submission := range submissionsMap {
pull.Submissions[submission.RoundNumber] = submission
+62
appview/filetree/filetree.go
···
+
package filetree
+
+
import (
+
"path/filepath"
+
"sort"
+
"strings"
+
)
+
+
type FileTreeNode struct {
+
Name string
+
Path string
+
IsDirectory bool
+
Children map[string]*FileTreeNode
+
}
+
+
// NewNode creates a new node
+
func newNode(name, path string, isDir bool) *FileTreeNode {
+
return &FileTreeNode{
+
Name: name,
+
Path: path,
+
IsDirectory: isDir,
+
Children: make(map[string]*FileTreeNode),
+
}
+
}
+
+
func FileTree(files []string) *FileTreeNode {
+
rootNode := newNode("", "", true)
+
+
sort.Strings(files)
+
+
for _, file := range files {
+
if file == "" {
+
continue
+
}
+
+
parts := strings.Split(filepath.Clean(file), "/")
+
if len(parts) == 0 {
+
continue
+
}
+
+
currentNode := rootNode
+
currentPath := ""
+
+
for i, part := range parts {
+
if currentPath == "" {
+
currentPath = part
+
} else {
+
currentPath = filepath.Join(currentPath, part)
+
}
+
+
isDir := i < len(parts)-1
+
+
if _, exists := currentNode.Children[part]; !exists {
+
currentNode.Children[part] = newNode(part, currentPath, isDir)
+
}
+
+
currentNode = currentNode.Children[part]
+
}
+
}
+
+
return rootNode
+
}
+126
appview/middleware/middleware.go
···
+
package middleware
+
+
import (
+
"context"
+
"log"
+
"net/http"
+
"strconv"
+
"time"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/xrpc"
+
"tangled.sh/tangled.sh/core/appview"
+
"tangled.sh/tangled.sh/core/appview/auth"
+
"tangled.sh/tangled.sh/core/appview/pagination"
+
)
+
+
type Middleware func(http.Handler) http.Handler
+
+
func AuthMiddleware(a *auth.Auth) Middleware {
+
return func(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
+
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
+
}
+
if r.Header.Get("HX-Request") == "true" {
+
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
+
w.Header().Set("HX-Redirect", "/login")
+
w.WriteHeader(http.StatusOK)
+
}
+
}
+
+
session, err := a.GetSession(r)
+
if session.IsNew || err != nil {
+
log.Printf("not logged in, redirecting")
+
redirectFunc(w, r)
+
return
+
}
+
+
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
+
if !ok || !authorized {
+
log.Printf("not logged in, redirecting")
+
redirectFunc(w, r)
+
return
+
}
+
+
// refresh if nearing expiry
+
// TODO: dedup with /login
+
expiryStr := session.Values[appview.SessionExpiry].(string)
+
expiry, err := time.Parse(time.RFC3339, expiryStr)
+
if err != nil {
+
log.Println("invalid expiry time", err)
+
redirectFunc(w, r)
+
return
+
}
+
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
+
did, ok2 := session.Values[appview.SessionDid].(string)
+
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
+
+
if !ok1 || !ok2 || !ok3 {
+
log.Println("invalid expiry time", err)
+
redirectFunc(w, r)
+
return
+
}
+
+
if time.Now().After(expiry) {
+
log.Println("token expired, refreshing ...")
+
+
client := xrpc.Client{
+
Host: pdsUrl,
+
Auth: &xrpc.AuthInfo{
+
Did: did,
+
AccessJwt: refreshJwt,
+
RefreshJwt: refreshJwt,
+
},
+
}
+
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
+
if err != nil {
+
log.Println("failed to refresh session", err)
+
redirectFunc(w, r)
+
return
+
}
+
+
sessionish := auth.RefreshSessionWrapper{atSession}
+
+
err = a.StoreSession(r, w, &sessionish, pdsUrl)
+
if err != nil {
+
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
+
return
+
}
+
+
log.Println("successfully refreshed token")
+
}
+
+
next.ServeHTTP(w, r)
+
})
+
}
+
}
+
+
func Paginate(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
page := pagination.FirstPage()
+
+
offsetVal := r.URL.Query().Get("offset")
+
if offsetVal != "" {
+
offset, err := strconv.Atoi(offsetVal)
+
if err != nil {
+
log.Println("invalid offset")
+
} else {
+
page.Offset = offset
+
}
+
}
+
+
limitVal := r.URL.Query().Get("limit")
+
if limitVal != "" {
+
limit, err := strconv.Atoi(limitVal)
+
if err != nil {
+
log.Println("invalid limit")
+
} else {
+
page.Limit = limit
+
}
+
}
+
+
ctx := context.WithValue(r.Context(), "page", page)
+
next.ServeHTTP(w, r.WithContext(ctx))
+
})
+
}
+2
appview/pages/funcmap.go
···
"time"
"github.com/dustin/go-humanize"
+
"tangled.sh/tangled.sh/core/appview/filetree"
"tangled.sh/tangled.sh/core/appview/pages/markup"
)
···
return template.HTML(data)
},
"cssContentHash": CssContentHash,
+
"fileTree": filetree.FileTree,
}
}
+136 -45
appview/pages/pages.go
···
"io/fs"
"log"
"net/http"
+
"os"
"path"
"path/filepath"
"slices"
···
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
"tangled.sh/tangled.sh/core/appview/pagination"
"tangled.sh/tangled.sh/core/appview/state/userutil"
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
···
var Files embed.FS
type Pages struct {
-
t map[string]*template.Template
+
t map[string]*template.Template
+
dev bool
+
embedFS embed.FS
+
templateDir string // Path to templates on disk for dev mode
}
-
func NewPages() *Pages {
-
templates := make(map[string]*template.Template)
+
func NewPages(dev bool) *Pages {
+
p := &Pages{
+
t: make(map[string]*template.Template),
+
dev: dev,
+
embedFS: Files,
+
templateDir: "appview/pages",
+
}
+
+
// Initial load of all templates
+
p.loadAllTemplates()
+
+
return p
+
}
+
func (p *Pages) loadAllTemplates() {
+
templates := make(map[string]*template.Template)
var fragmentPaths []string
+
+
// Use embedded FS for initial loading
// First, collect all fragment paths
-
err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
+
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
-
if d.IsDir() {
return nil
}
-
if !strings.HasSuffix(path, ".html") {
return nil
}
-
if !strings.Contains(path, "fragments/") {
return nil
}
-
name := strings.TrimPrefix(path, "templates/")
name = strings.TrimSuffix(name, ".html")
-
tmpl, err := template.New(name).
Funcs(funcMap()).
-
ParseFS(Files, path)
+
ParseFS(p.embedFS, path)
if err != nil {
log.Fatalf("setting up fragment: %v", err)
}
-
templates[name] = tmpl
fragmentPaths = append(fragmentPaths, path)
log.Printf("loaded fragment: %s", name)
···
}
// Then walk through and setup the rest of the templates
-
err = fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
+
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
-
if d.IsDir() {
return nil
}
-
if !strings.HasSuffix(path, "html") {
return nil
}
-
// Skip fragments as they've already been loaded
if strings.Contains(path, "fragments/") {
return nil
}
-
// Skip layouts
if strings.Contains(path, "layouts/") {
return nil
}
-
name := strings.TrimPrefix(path, "templates/")
name = strings.TrimSuffix(name, ".html")
-
// Add the page template on top of the base
allPaths := []string{}
allPaths = append(allPaths, "templates/layouts/*.html")
···
allPaths = append(allPaths, path)
tmpl, err := template.New(name).
Funcs(funcMap()).
-
ParseFS(Files, allPaths...)
+
ParseFS(p.embedFS, allPaths...)
if err != nil {
return fmt.Errorf("setting up template: %w", err)
}
-
templates[name] = tmpl
log.Printf("loaded template: %s", name)
return nil
···
}
log.Printf("total templates loaded: %d", len(templates))
+
p.t = templates
+
}
-
return &Pages{
-
t: templates,
+
// loadTemplateFromDisk loads a template from the filesystem in dev mode
+
func (p *Pages) loadTemplateFromDisk(name string) error {
+
if !p.dev {
+
return nil
+
}
+
+
log.Printf("reloading template from disk: %s", name)
+
+
// Find all fragments first
+
var fragmentPaths []string
+
err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error {
+
if err != nil {
+
return err
+
}
+
if d.IsDir() {
+
return nil
+
}
+
if !strings.HasSuffix(path, ".html") {
+
return nil
+
}
+
if !strings.Contains(path, "fragments/") {
+
return nil
+
}
+
fragmentPaths = append(fragmentPaths, path)
+
return nil
+
})
+
if err != nil {
+
return fmt.Errorf("walking disk template dir for fragments: %w", err)
+
}
+
+
// Find the template path on disk
+
templatePath := filepath.Join(p.templateDir, "templates", name+".html")
+
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
+
return fmt.Errorf("template not found on disk: %s", name)
+
}
+
+
// Create a new template
+
tmpl := template.New(name).Funcs(funcMap())
+
+
// Parse layouts
+
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
+
layouts, err := filepath.Glob(layoutGlob)
+
if err != nil {
+
return fmt.Errorf("finding layout templates: %w", err)
+
}
+
+
// Create paths for parsing
+
allFiles := append(layouts, fragmentPaths...)
+
allFiles = append(allFiles, templatePath)
+
+
// Parse all templates
+
tmpl, err = tmpl.ParseFiles(allFiles...)
+
if err != nil {
+
return fmt.Errorf("parsing template files: %w", err)
}
+
+
// Update the template in the map
+
p.t[name] = tmpl
+
log.Printf("template reloaded from disk: %s", name)
+
return nil
}
-
type LoginParams struct {
+
func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
+
// In dev mode, reload the template from disk before executing
+
if p.dev {
+
if err := p.loadTemplateFromDisk(templateName); err != nil {
+
log.Printf("warning: failed to reload template %s from disk: %v", templateName, err)
+
// Continue with the existing template
+
}
+
}
+
+
tmpl, exists := p.t[templateName]
+
if !exists {
+
return fmt.Errorf("template not found: %s", templateName)
+
}
+
+
if base == "" {
+
return tmpl.Execute(w, params)
+
} else {
+
return tmpl.ExecuteTemplate(w, base, params)
+
}
}
func (p *Pages) execute(name string, w io.Writer, params any) error {
-
return p.t[name].ExecuteTemplate(w, "layouts/base", params)
+
return p.executeOrReload(name, w, "layouts/base", params)
}
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
-
return p.t[name].Execute(w, params)
+
return p.executeOrReload(name, w, "", params)
}
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
-
return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
+
return p.executeOrReload(name, w, "layouts/repobase", params)
+
}
+
+
type LoginParams struct {
}
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
}
type RepoCommitParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Active string
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
EmailToDidOrHandle map[string]string
+
types.RepoCommitResponse
-
EmailToDidOrHandle map[string]string
}
func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
···
}
type RepoIssuesParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Active string
-
Issues []db.Issue
-
DidHandleMap map[string]string
-
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
Issues []db.Issue
+
DidHandleMap map[string]string
+
Page pagination.Page
FilteringByOpen bool
}
···
}
type RepoSinglePullParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Active string
-
DidHandleMap map[string]string
-
Pull *db.Pull
-
PullSourceRepo *db.Repo
-
MergeCheck types.MergeCheckResponse
-
ResubmitCheck ResubmitResult
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
DidHandleMap map[string]string
+
Pull *db.Pull
+
MergeCheck types.MergeCheckResponse
+
ResubmitCheck ResubmitResult
}
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
DidHandleMap map[string]string
RepoInfo RepoInfo
Pull *db.Pull
-
Diff types.NiceDiff
+
Diff *types.NiceDiff
Round int
Submission *db.PullSubmission
}
···
}
func (p *Pages) Static() http.Handler {
+
if p.dev {
+
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
+
}
+
sub, err := fs.Sub(Files, "static")
if err != nil {
log.Fatalf("no static dir found? that's crazy: %v", err)
+4 -17
appview/pages/templates/repo/fragments/diff.html
···
{{ $diff := index . 1 }}
{{ $commit := $diff.Commit }}
{{ $stat := $diff.Stat }}
+
{{ $fileTree := fileTree $diff.ChangedFiles }}
{{ $diff := $diff.Diff }}
{{ $this := $commit.This }}
···
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
{{ block "statPill" $stat }} {{ end }}
</div>
-
<div class="overflow-x-auto">
-
{{ range $diff }}
-
<ul class="dark:text-gray-200">
-
{{ if .IsDelete }}
-
<li><a href="#file-{{ .Name.Old }}" class="dark:hover:text-gray-300">{{ .Name.Old }}</a></li>
-
{{ else }}
-
<li><a href="#file-{{ .Name.New }}" class="dark:hover:text-gray-300">{{ .Name.New }}</a></li>
-
{{ end }}
-
</ul>
-
{{ end }}
-
</div>
+
{{ block "fileTree" $fileTree }} {{ end }}
</div>
</section>
···
<summary class="list-none cursor-pointer sticky top-0">
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
-
<div class="flex gap-1 items-center" style="direction: ltr;">
+
<div class="flex gap-1 items-center">
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
{{ if .IsNew }}
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
···
{{ block "statPill" .Stats }} {{ end }}
</div>
-
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
+
<div class="flex gap-2 items-center overflow-x-auto">
{{ if .IsDelete }}
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
{{ .Name.Old }}
···
{{ else if .IsCopy }}
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
This file has been copied.
-
</p>
-
{{ else if .IsRename }}
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
-
This file has been renamed.
</p>
{{ else if .IsBinary }}
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+27
appview/pages/templates/repo/fragments/filetree.html
···
+
{{ define "fileTree" }}
+
{{ if and .Name .IsDirectory }}
+
<details open>
+
<summary class="cursor-pointer list-none pt-1">
+
<span class="inline-flex items-center gap-2 ">
+
{{ i "folder" "w-3 h-3 fill-current" }}
+
<span class="text-black dark:text-white">{{ .Name }}</span>
+
</span>
+
</summary>
+
<div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700">
+
{{ range $child := .Children }}
+
{{ block "fileTree" $child }} {{ end }}
+
{{ end }}
+
</div>
+
</details>
+
{{ else if .Name }}
+
<div class="flex items-center gap-2 pt-1">
+
{{ i "file" "w-3 h-3" }}
+
<a href="#file-{{ .Path }}" class="text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
+
</div>
+
{{ else }}
+
{{ range $child := .Children }}
+
{{ block "fileTree" $child }} {{ end }}
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+2 -7
appview/pages/templates/repo/fragments/interdiff.html
···
{{ define "repo/fragments/interdiff" }}
{{ $repo := index . 0 }}
{{ $x := index . 1 }}
+
{{ $fileTree := fileTree $x.AffectedFiles }}
{{ $diff := $x.Files }}
<section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
···
<div class="flex gap-2 items-center">
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
</div>
-
<div class="overflow-x-auto">
-
<ul class="dark:text-gray-200">
-
{{ range $diff }}
-
<li><a href="#file-{{ .Name }}" class="dark:hover:text-gray-300">{{ .Name }}</a></li>
-
{{ end }}
-
</ul>
-
</div>
+
{{ block "fileTree" $fileTree }} {{ end }}
</div>
</section>
+38
appview/pages/templates/repo/issues/issues.html
···
</div>
{{ end }}
</div>
+
+
{{ block "pagination" . }} {{ end }}
+
+
{{ end }}
+
+
{{ define "pagination" }}
+
<div class="flex justify-end mt-4 gap-2">
+
{{ $currentState := "closed" }}
+
{{ if .FilteringByOpen }}
+
{{ $currentState = "open" }}
+
{{ end }}
+
+
{{ if gt .Page.Offset 0 }}
+
{{ $prev := .Page.Previous }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
+
>
+
{{ i "chevron-left" "w-4 h-4" }}
+
previous
+
</a>
+
{{ else }}
+
<div></div>
+
{{ end }}
+
+
{{ if eq (len .Issues) .Page.Limit }}
+
{{ $next := .Page.Next }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
+
>
+
next
+
{{ i "chevron-right" "w-4 h-4" }}
+
</a>
+
{{ end }}
+
</div>
{{ end }}
+8 -8
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
</span>
{{ if not .Pull.IsPatchBased }}
<span>from
-
{{ if not .Pull.IsBranchBased }}
-
<a href="/{{ $owner }}/{{ .PullSourceRepo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSourceRepo.Name }}</a>
+
{{ if .Pull.IsForkBased }}
+
{{ if .Pull.PullSource.Repo }}
+
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>
+
{{ else }}
+
<span class="italic">[deleted fork]</span>
+
{{ end }}
{{ end }}
-
{{ $fullRepo := .RepoInfo.FullName }}
-
{{ if not .Pull.IsBranchBased }}
-
{{ $fullRepo = printf "%s/%s" $owner .PullSourceRepo.Name }}
-
{{ end }}
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
-
<a href="/{{ $fullRepo }}/tree/{{ .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
+
{{ .Pull.PullSource.Branch }}
</span>
</span>
{{ end }}
···
</section>
-
{{ end }}
+
{{ end }}
+7 -3
appview/pages/templates/repo/pulls/pull.html
···
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ if not $.Pull.IsPatchBased }}
{{ $fullRepo := $.RepoInfo.FullName }}
-
{{ if not $.Pull.IsBranchBased }}
-
{{ $fullRepo = printf "%s/%s" $owner $.PullSourceRepo.Name }}
+
{{ if $.Pull.IsForkBased }}
+
{{ if $.Pull.PullSource.Repo }}
+
{{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }}
+
<a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a>
+
{{ else }}
+
<span class="font-mono">{{ slice .SHA 0 8 }}</span>
+
{{ end }}
{{ end }}
-
<a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a>
{{ else }}
<span class="font-mono">{{ slice .SHA 0 8 }}</span>
{{ end }}
+1 -1
appview/pages/templates/user/profile.html
···
{{ if gt $stats.Closed 0 }}
-
<span class="px-2 py-1/2 text-sm rounded text-black dark:text-white bg-gray-50 dark:bg-gray-700 ">
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
{{$stats.Closed}} closed
</span>
{{ end }}
+31
appview/pagination/page.go
···
+
package pagination
+
+
type Page struct {
+
Offset int // where to start from
+
Limit int // number of items in a page
+
}
+
+
func FirstPage() Page {
+
return Page{
+
Offset: 0,
+
Limit: 10,
+
}
+
}
+
+
func (p Page) Previous() Page {
+
if p.Offset-p.Limit < 0 {
+
return FirstPage()
+
} else {
+
return Page{
+
Offset: p.Offset - p.Limit,
+
Limit: p.Limit,
+
}
+
}
+
}
+
+
func (p Page) Next() Page {
+
return Page{
+
Offset: p.Offset + p.Limit,
+
Limit: p.Limit,
+
}
+
}
+451
appview/settings/settings.go
···
+
package settings
+
+
import (
+
"database/sql"
+
"errors"
+
"fmt"
+
"log"
+
"net/http"
+
"net/url"
+
"strings"
+
"time"
+
+
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
+
"tangled.sh/tangled.sh/core/appview/auth"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/email"
+
"tangled.sh/tangled.sh/core/appview/middleware"
+
"tangled.sh/tangled.sh/core/appview/pages"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
"github.com/gliderlabs/ssh"
+
"github.com/google/uuid"
+
)
+
+
type Settings struct {
+
Db *db.DB
+
Auth *auth.Auth
+
Pages *pages.Pages
+
Config *appview.Config
+
}
+
+
func (s *Settings) Router() http.Handler {
+
r := chi.NewRouter()
+
+
r.Use(middleware.AuthMiddleware(s.Auth))
+
+
r.Get("/", s.settings)
+
+
r.Route("/keys", func(r chi.Router) {
+
r.Put("/", s.keys)
+
r.Delete("/", s.keys)
+
})
+
+
r.Route("/emails", func(r chi.Router) {
+
r.Put("/", s.emails)
+
r.Delete("/", s.emails)
+
r.Get("/verify", s.emailsVerify)
+
r.Post("/verify/resend", s.emailsVerifyResend)
+
r.Post("/primary", s.emailsPrimary)
+
})
+
+
return r
+
}
+
+
func (s *Settings) settings(w http.ResponseWriter, r *http.Request) {
+
user := s.Auth.GetUser(r)
+
pubKeys, err := db.GetPublicKeys(s.Db, user.Did)
+
if err != nil {
+
log.Println(err)
+
}
+
+
emails, err := db.GetAllEmails(s.Db, user.Did)
+
if err != nil {
+
log.Println(err)
+
}
+
+
s.Pages.Settings(w, pages.SettingsParams{
+
LoggedInUser: user,
+
PubKeys: pubKeys,
+
Emails: emails,
+
})
+
}
+
+
// buildVerificationEmail creates an email.Email struct for verification emails
+
func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email {
+
verifyURL := s.verifyUrl(did, emailAddr, code)
+
+
return email.Email{
+
APIKey: s.Config.ResendApiKey,
+
From: "noreply@notifs.tangled.sh",
+
To: emailAddr,
+
Subject: "Verify your Tangled email",
+
Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
+
` + verifyURL,
+
Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
+
<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
+
}
+
}
+
+
// sendVerificationEmail handles the common logic for sending verification emails
+
func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
+
emailToSend := s.buildVerificationEmail(emailAddr, did, code)
+
+
err := email.SendEmail(emailToSend)
+
if err != nil {
+
log.Printf("sending email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
+
return err
+
}
+
+
return nil
+
}
+
+
func (s *Settings) emails(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
s.Pages.Notice(w, "settings-emails", "Unimplemented.")
+
log.Println("unimplemented")
+
return
+
case http.MethodPut:
+
did := s.Auth.GetDid(r)
+
emAddr := r.FormValue("email")
+
emAddr = strings.TrimSpace(emAddr)
+
+
if !email.IsValidEmail(emAddr) {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
+
return
+
}
+
+
// check if email already exists in database
+
existingEmail, err := db.GetEmail(s.Db, did, emAddr)
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
+
log.Printf("checking for existing email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
+
if err == nil {
+
if existingEmail.Verified {
+
s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
+
return
+
}
+
+
code := uuid.New().String()
+
+
// Begin transaction
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
if err := db.AddEmail(tx, db.Email{
+
Did: did,
+
Address: emAddr,
+
Verified: false,
+
VerificationCode: code,
+
}); err != nil {
+
log.Printf("adding email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
+
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
+
return
+
}
+
+
// Commit transaction
+
if err := tx.Commit(); err != nil {
+
log.Printf("failed to commit transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
+
return
+
case http.MethodDelete:
+
did := s.Auth.GetDid(r)
+
emailAddr := r.FormValue("email")
+
emailAddr = strings.TrimSpace(emailAddr)
+
+
// Begin transaction
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
+
log.Printf("deleting email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
+
return
+
}
+
+
// Commit transaction
+
if err := tx.Commit(); err != nil {
+
log.Printf("failed to commit transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
+
return
+
}
+
+
s.Pages.HxLocation(w, "/settings")
+
return
+
}
+
}
+
+
func (s *Settings) verifyUrl(did string, email string, code string) string {
+
var appUrl string
+
if s.Config.Dev {
+
appUrl = "http://" + s.Config.ListenAddr
+
} else {
+
appUrl = "https://tangled.sh"
+
}
+
+
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
+
}
+
+
func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) {
+
q := r.URL.Query()
+
+
// Get the parameters directly from the query
+
emailAddr := q.Get("email")
+
did := q.Get("did")
+
code := q.Get("code")
+
+
valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code)
+
if err != nil {
+
log.Printf("checking email verification: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
+
return
+
}
+
+
if !valid {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
+
return
+
}
+
+
// Mark email as verified in the database
+
if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil {
+
log.Printf("marking email as verified: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
+
return
+
}
+
+
http.Redirect(w, r, "/settings", http.StatusSeeOther)
+
}
+
+
func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid request method.")
+
return
+
}
+
+
did := s.Auth.GetDid(r)
+
emAddr := r.FormValue("email")
+
emAddr = strings.TrimSpace(emAddr)
+
+
if !email.IsValidEmail(emAddr) {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
+
return
+
}
+
+
// Check if email exists and is unverified
+
existingEmail, err := db.GetEmail(s.Db, did, emAddr)
+
if err != nil {
+
if errors.Is(err, sql.ErrNoRows) {
+
s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
+
} else {
+
log.Printf("checking for existing email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
}
+
return
+
}
+
+
if existingEmail.Verified {
+
s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
+
return
+
}
+
+
// Check if last verification email was sent less than 10 minutes ago
+
if existingEmail.LastSent != nil {
+
timeSinceLastSent := time.Since(*existingEmail.LastSent)
+
if timeSinceLastSent < 10*time.Minute {
+
waitTime := 10*time.Minute - timeSinceLastSent
+
s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
+
return
+
}
+
}
+
+
// Generate new verification code
+
code := uuid.New().String()
+
+
// Begin transaction
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
// Update the verification code and last sent time
+
if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
+
log.Printf("updating email verification: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
return
+
}
+
+
// Send verification email
+
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
+
return
+
}
+
+
// Commit transaction
+
if err := tx.Commit(); err != nil {
+
log.Printf("failed to commit transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
+
}
+
+
func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) {
+
did := s.Auth.GetDid(r)
+
emailAddr := r.FormValue("email")
+
emailAddr = strings.TrimSpace(emailAddr)
+
+
if emailAddr == "" {
+
s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
+
return
+
}
+
+
if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil {
+
log.Printf("setting primary email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
+
return
+
}
+
+
s.Pages.HxLocation(w, "/settings")
+
}
+
+
func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
s.Pages.Notice(w, "settings-keys", "Unimplemented.")
+
log.Println("unimplemented")
+
return
+
case http.MethodPut:
+
did := s.Auth.GetDid(r)
+
key := r.FormValue("key")
+
key = strings.TrimSpace(key)
+
name := r.FormValue("name")
+
client, _ := s.Auth.AuthorizedClient(r)
+
+
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
+
if err != nil {
+
log.Printf("parsing public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
+
return
+
}
+
+
rkey := appview.TID()
+
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start tx; adding public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
+
log.Printf("adding public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to add public key.")
+
return
+
}
+
+
// store in pds too
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.PublicKeyNSID,
+
Repo: did,
+
Rkey: rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.PublicKey{
+
Created: time.Now().Format(time.RFC3339),
+
Key: key,
+
Name: name,
+
}},
+
})
+
// invalid record
+
if err != nil {
+
log.Printf("failed to create record: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to create record.")
+
return
+
}
+
+
log.Println("created atproto record: ", resp.Uri)
+
+
err = tx.Commit()
+
if err != nil {
+
log.Printf("failed to commit tx; adding public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
+
return
+
}
+
+
s.Pages.HxLocation(w, "/settings")
+
return
+
+
case http.MethodDelete:
+
did := s.Auth.GetDid(r)
+
q := r.URL.Query()
+
+
name := q.Get("name")
+
rkey := q.Get("rkey")
+
key := q.Get("key")
+
+
log.Println(name)
+
log.Println(rkey)
+
log.Println(key)
+
+
client, _ := s.Auth.AuthorizedClient(r)
+
+
if err := db.RemovePublicKey(s.Db, did, name, key); err != nil {
+
log.Printf("removing public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to remove public key.")
+
return
+
}
+
+
if rkey != "" {
+
// remove from pds too
+
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.PublicKeyNSID,
+
Repo: did,
+
Rkey: rkey,
+
})
+
+
// invalid record
+
if err != nil {
+
log.Printf("failed to delete record from PDS: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
+
return
+
}
+
}
+
log.Println("deleted successfully")
+
+
s.Pages.HxLocation(w, "/settings")
+
return
+
}
+
}
+2 -1
appview/state/follow.go
···
comatproto "github.com/bluesky-social/indigo/api/atproto"
lexutil "github.com/bluesky-social/indigo/lex/util"
tangled "tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
switch r.Method {
case http.MethodPost:
createdAt := time.Now().Format(time.RFC3339)
-
rkey := s.TID()
+
rkey := appview.TID()
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.GraphFollowNSID,
Repo: currentUser.Did,
+7 -92
appview/state/middleware.go
···
"slices"
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
-
"github.com/bluesky-social/indigo/xrpc"
"github.com/go-chi/chi/v5"
-
"tangled.sh/tangled.sh/core/appview"
-
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/middleware"
)
-
type Middleware func(http.Handler) http.Handler
-
-
func AuthMiddleware(s *State) Middleware {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
-
}
-
if r.Header.Get("HX-Request") == "true" {
-
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
-
w.Header().Set("HX-Redirect", "/login")
-
w.WriteHeader(http.StatusOK)
-
}
-
}
-
-
session, err := s.auth.GetSession(r)
-
if session.IsNew || err != nil {
-
log.Printf("not logged in, redirecting")
-
redirectFunc(w, r)
-
return
-
}
-
-
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
-
if !ok || !authorized {
-
log.Printf("not logged in, redirecting")
-
redirectFunc(w, r)
-
return
-
}
-
-
// refresh if nearing expiry
-
// TODO: dedup with /login
-
expiryStr := session.Values[appview.SessionExpiry].(string)
-
expiry, err := time.Parse(time.RFC3339, expiryStr)
-
if err != nil {
-
log.Println("invalid expiry time", err)
-
redirectFunc(w, r)
-
return
-
}
-
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
-
did, ok2 := session.Values[appview.SessionDid].(string)
-
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
-
-
if !ok1 || !ok2 || !ok3 {
-
log.Println("invalid expiry time", err)
-
redirectFunc(w, r)
-
return
-
}
-
-
if time.Now().After(expiry) {
-
log.Println("token expired, refreshing ...")
-
-
client := xrpc.Client{
-
Host: pdsUrl,
-
Auth: &xrpc.AuthInfo{
-
Did: did,
-
AccessJwt: refreshJwt,
-
RefreshJwt: refreshJwt,
-
},
-
}
-
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
-
if err != nil {
-
log.Println("failed to refresh session", err)
-
redirectFunc(w, r)
-
return
-
}
-
-
sessionish := auth.RefreshSessionWrapper{atSession}
-
-
err = s.auth.StoreSession(r, w, &sessionish, pdsUrl)
-
if err != nil {
-
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
-
return
-
}
-
-
log.Println("successfully refreshed token")
-
}
-
-
next.ServeHTTP(w, r)
-
})
-
}
-
}
-
-
func knotRoleMiddleware(s *State, group string) Middleware {
+
func knotRoleMiddleware(s *State, group string) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// requires auth also
···
}
}
-
func KnotOwner(s *State) Middleware {
+
func KnotOwner(s *State) middleware.Middleware {
return knotRoleMiddleware(s, "server:owner")
}
-
func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware {
+
func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// requires auth also
···
})
}
-
func ResolveIdent(s *State) Middleware {
+
func ResolveIdent(s *State) middleware.Middleware {
excluded := []string{"favicon.ico"}
return func(next http.Handler) http.Handler {
···
}
}
-
func ResolveRepo(s *State) Middleware {
+
func ResolveRepo(s *State) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
repoName := chi.URLParam(req, "repo")
···
}
// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
-
func ResolvePull(s *State) Middleware {
+
func ResolvePull(s *State) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f, err := fullyResolvedRepo(r)
+18 -22
appview/state/pull.go
···
"time"
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
···
resubmitResult = s.resubmitCheck(f, pull)
}
-
var pullSourceRepo *db.Repo
-
if pull.PullSource != nil {
-
if pull.PullSource.RepoAt != nil {
-
pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
-
if err != nil {
-
log.Printf("failed to get repo by at uri: %v", err)
-
return
-
}
-
}
-
}
-
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(s, user),
-
DidHandleMap: didHandleMap,
-
Pull: pull,
-
PullSourceRepo: pullSourceRepo,
-
MergeCheck: mergeCheckResponse,
-
ResubmitCheck: resubmitResult,
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
DidHandleMap: didHandleMap,
+
Pull: pull,
+
MergeCheck: mergeCheckResponse,
+
ResubmitCheck: resubmitResult,
})
}
···
}
}
+
diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
+
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
LoggedInUser: user,
DidHandleMap: didHandleMap,
···
Pull: pull,
Round: roundIdInt,
Submission: pull.Submissions[roundIdInt],
-
Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
+
Diff: &diff,
})
}
···
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoPullCommentNSID,
Repo: user.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoPullComment{
Repo: &atUri,
···
return
}
+
if !caps.PullRequests.FormatPatch {
+
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
+
return
+
}
+
// Handle the PR creation based on the type
if isBranchBased {
if !caps.PullRequests.BranchSubmissions {
···
sourceRev := comparison.Rev2
patch := comparison.Patch
-
if patchutil.IsPatchValid(patch) {
+
if !patchutil.IsPatchValid(patch) {
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
return
}
···
body = formatPatches[0].Body
}
-
rkey := s.TID()
+
rkey := appview.TID()
initialSubmission := db.PullSubmission{
Patch: patch,
SourceRev: sourceRev,
+14 -5
appview/state/repo.go
···
"github.com/go-chi/chi/v5"
"github.com/go-git/go-git/v5/plumbing"
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
"tangled.sh/tangled.sh/core/appview/pagination"
"tangled.sh/tangled.sh/core/types"
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueStateNSID,
Repo: user.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoIssueState{
Issue: issue.IssueAt,
···
commentId := mathrand.IntN(1000000)
-
rkey := s.TID()
+
rkey := appview.TID()
err := db.NewIssueComment(s.db, &db.Comment{
OwnerDid: user.Did,
···
isOpen = true
+
page, ok := r.Context().Value("page").(pagination.Page)
+
if !ok {
+
log.Println("failed to get page")
+
page = pagination.FirstPage()
+
}
+
user := s.auth.GetUser(r)
f, err := fullyResolvedRepo(r)
if err != nil {
···
return
-
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
+
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
if err != nil {
log.Println("failed to get issues", err)
s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
Issues: issues,
DidHandleMap: didHandleMap,
FilteringByOpen: isOpen,
+
Page: page,
})
return
···
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueNSID,
Repo: user.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoIssue{
Repo: atUri,
···
sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
sourceAt := f.RepoAt.String()
-
rkey := s.TID()
+
rkey := appview.TID()
repo := &db.Repo{
Did: user.Did,
Name: forkName,
+26 -23
appview/state/router.go
···
"strings"
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/appview/middleware"
+
"tangled.sh/tangled.sh/core/appview/settings"
"tangled.sh/tangled.sh/core/appview/state/userutil"
)
···
r.Get("/blob/{ref}/raw/*", s.RepoBlobRaw)
r.Route("/issues", func(r chi.Router) {
-
r.Get("/", s.RepoIssues)
+
r.With(middleware.Paginate).Get("/", s.RepoIssues)
r.Get("/{issue}", s.RepoSingleIssue)
r.Group(func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/new", s.NewIssue)
r.Post("/new", s.NewIssue)
r.Post("/{issue}/comment", s.NewIssueComment)
···
})
r.Route("/fork", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/", s.ForkRepo)
r.Post("/", s.ForkRepo)
})
r.Route("/pulls", func(r chi.Router) {
r.Get("/", s.RepoPulls)
-
r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) {
r.Get("/", s.NewPull)
r.Get("/patch-upload", s.PatchUploadFragment)
r.Post("/validate-patch", s.ValidatePatch)
···
r.Get("/", s.RepoPullPatch)
r.Get("/interdiff", s.RepoPullInterdiff)
r.Get("/actions", s.PullActions)
-
r.With(AuthMiddleware(s)).Route("/comment", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) {
r.Get("/", s.PullComment)
r.Post("/", s.PullComment)
})
···
})
r.Group(func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Route("/resubmit", func(r chi.Router) {
r.Get("/", s.ResubmitPull)
r.Post("/", s.ResubmitPull)
···
// settings routes, needs auth
r.Group(func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
// repo description can only be edited by owner
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
r.Put("/", s.RepoDescription)
···
r.Get("/", s.Timeline)
-
r.With(AuthMiddleware(s)).Post("/logout", s.Logout)
+
r.With(middleware.AuthMiddleware(s.auth)).Post("/logout", s.Logout)
r.Route("/login", func(r chi.Router) {
r.Get("/", s.Login)
···
})
r.Route("/knots", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/", s.Knots)
r.Post("/key", s.RegistrationKey)
···
r.Route("/repo", func(r chi.Router) {
r.Route("/new", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/", s.NewRepo)
r.Post("/", s.NewRepo)
})
// r.Post("/import", s.ImportRepo)
})
-
r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) {
r.Post("/", s.Follow)
r.Delete("/", s.Follow)
})
-
r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) {
r.Post("/", s.Star)
r.Delete("/", s.Star)
})
-
r.Route("/settings", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
-
r.Get("/", s.Settings)
-
r.Put("/keys", s.SettingsKeys)
-
r.Delete("/keys", s.SettingsKeys)
-
r.Put("/emails", s.SettingsEmails)
-
r.Delete("/emails", s.SettingsEmails)
-
r.Get("/emails/verify", s.SettingsEmailsVerify)
-
r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend)
-
r.Post("/emails/primary", s.SettingsEmailsPrimary)
-
})
+
r.Mount("/settings", s.SettingsRouter())
r.Get("/keys/{user}", s.Keys)
···
})
return r
}
+
+
func (s *State) SettingsRouter() http.Handler {
+
settings := &settings.Settings{
+
Db: s.db,
+
Auth: s.auth,
+
Pages: s.pages,
+
Config: s.config,
+
}
+
+
return settings.Router()
+
}
-416
appview/state/settings.go
···
-
package state
-
-
import (
-
"database/sql"
-
"errors"
-
"fmt"
-
"log"
-
"net/http"
-
"net/url"
-
"strings"
-
"time"
-
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
-
"github.com/gliderlabs/ssh"
-
"github.com/google/uuid"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/email"
-
"tangled.sh/tangled.sh/core/appview/pages"
-
)
-
-
func (s *State) Settings(w http.ResponseWriter, r *http.Request) {
-
user := s.auth.GetUser(r)
-
pubKeys, err := db.GetPublicKeys(s.db, user.Did)
-
if err != nil {
-
log.Println(err)
-
}
-
-
emails, err := db.GetAllEmails(s.db, user.Did)
-
if err != nil {
-
log.Println(err)
-
}
-
-
s.pages.Settings(w, pages.SettingsParams{
-
LoggedInUser: user,
-
PubKeys: pubKeys,
-
Emails: emails,
-
})
-
}
-
-
// buildVerificationEmail creates an email.Email struct for verification emails
-
func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email {
-
verifyURL := s.verifyUrl(did, emailAddr, code)
-
-
return email.Email{
-
APIKey: s.config.ResendApiKey,
-
From: "noreply@notifs.tangled.sh",
-
To: emailAddr,
-
Subject: "Verify your Tangled email",
-
Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
-
` + verifyURL,
-
Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
-
<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
-
}
-
}
-
-
// sendVerificationEmail handles the common logic for sending verification emails
-
func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
-
emailToSend := s.buildVerificationEmail(emailAddr, did, code)
-
-
err := email.SendEmail(emailToSend)
-
if err != nil {
-
log.Printf("sending email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
-
return err
-
}
-
-
return nil
-
}
-
-
func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) {
-
switch r.Method {
-
case http.MethodGet:
-
s.pages.Notice(w, "settings-emails", "Unimplemented.")
-
log.Println("unimplemented")
-
return
-
case http.MethodPut:
-
did := s.auth.GetDid(r)
-
emAddr := r.FormValue("email")
-
emAddr = strings.TrimSpace(emAddr)
-
-
if !email.IsValidEmail(emAddr) {
-
s.pages.Notice(w, "settings-emails-error", "Invalid email address.")
-
return
-
}
-
-
// check if email already exists in database
-
existingEmail, err := db.GetEmail(s.db, did, emAddr)
-
if err != nil && !errors.Is(err, sql.ErrNoRows) {
-
log.Printf("checking for existing email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
-
if err == nil {
-
if existingEmail.Verified {
-
s.pages.Notice(w, "settings-emails-error", "This email is already verified.")
-
return
-
}
-
-
s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
-
return
-
}
-
-
code := uuid.New().String()
-
-
// Begin transaction
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
if err := db.AddEmail(tx, db.Email{
-
Did: did,
-
Address: emAddr,
-
Verified: false,
-
VerificationCode: code,
-
}); err != nil {
-
log.Printf("adding email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
-
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
-
return
-
}
-
-
// Commit transaction
-
if err := tx.Commit(); err != nil {
-
log.Printf("failed to commit transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
-
s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
-
return
-
case http.MethodDelete:
-
did := s.auth.GetDid(r)
-
emailAddr := r.FormValue("email")
-
emailAddr = strings.TrimSpace(emailAddr)
-
-
// Begin transaction
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
-
log.Printf("deleting email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
-
return
-
}
-
-
// Commit transaction
-
if err := tx.Commit(); err != nil {
-
log.Printf("failed to commit transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, "/settings")
-
return
-
}
-
}
-
-
func (s *State) verifyUrl(did string, email string, code string) string {
-
var appUrl string
-
if s.config.Dev {
-
appUrl = "http://" + s.config.ListenAddr
-
} else {
-
appUrl = "https://tangled.sh"
-
}
-
-
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
-
}
-
-
func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) {
-
q := r.URL.Query()
-
-
// Get the parameters directly from the query
-
emailAddr := q.Get("email")
-
did := q.Get("did")
-
code := q.Get("code")
-
-
valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code)
-
if err != nil {
-
log.Printf("checking email verification: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
-
return
-
}
-
-
if !valid {
-
s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
-
return
-
}
-
-
// Mark email as verified in the database
-
if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil {
-
log.Printf("marking email as verified: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
-
return
-
}
-
-
http.Redirect(w, r, "/settings", http.StatusSeeOther)
-
}
-
-
func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) {
-
if r.Method != http.MethodPost {
-
s.pages.Notice(w, "settings-emails-error", "Invalid request method.")
-
return
-
}
-
-
did := s.auth.GetDid(r)
-
emAddr := r.FormValue("email")
-
emAddr = strings.TrimSpace(emAddr)
-
-
if !email.IsValidEmail(emAddr) {
-
s.pages.Notice(w, "settings-emails-error", "Invalid email address.")
-
return
-
}
-
-
// Check if email exists and is unverified
-
existingEmail, err := db.GetEmail(s.db, did, emAddr)
-
if err != nil {
-
if errors.Is(err, sql.ErrNoRows) {
-
s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
-
} else {
-
log.Printf("checking for existing email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
}
-
return
-
}
-
-
if existingEmail.Verified {
-
s.pages.Notice(w, "settings-emails-error", "This email is already verified.")
-
return
-
}
-
-
// Check if last verification email was sent less than 10 minutes ago
-
if existingEmail.LastSent != nil {
-
timeSinceLastSent := time.Since(*existingEmail.LastSent)
-
if timeSinceLastSent < 10*time.Minute {
-
waitTime := 10*time.Minute - timeSinceLastSent
-
s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
-
return
-
}
-
}
-
-
// Generate new verification code
-
code := uuid.New().String()
-
-
// Begin transaction
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
// Update the verification code and last sent time
-
if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
-
log.Printf("updating email verification: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
return
-
}
-
-
// Send verification email
-
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
-
return
-
}
-
-
// Commit transaction
-
if err := tx.Commit(); err != nil {
-
log.Printf("failed to commit transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
return
-
}
-
-
s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
-
}
-
-
func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) {
-
did := s.auth.GetDid(r)
-
emailAddr := r.FormValue("email")
-
emailAddr = strings.TrimSpace(emailAddr)
-
-
if emailAddr == "" {
-
s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
-
return
-
}
-
-
if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil {
-
log.Printf("setting primary email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, "/settings")
-
}
-
-
func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) {
-
switch r.Method {
-
case http.MethodGet:
-
s.pages.Notice(w, "settings-keys", "Unimplemented.")
-
log.Println("unimplemented")
-
return
-
case http.MethodPut:
-
did := s.auth.GetDid(r)
-
key := r.FormValue("key")
-
key = strings.TrimSpace(key)
-
name := r.FormValue("name")
-
client, _ := s.auth.AuthorizedClient(r)
-
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
-
if err != nil {
-
log.Printf("parsing public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
-
return
-
}
-
-
rkey := s.TID()
-
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start tx; adding public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
-
log.Printf("adding public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to add public key.")
-
return
-
}
-
-
// store in pds too
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
-
Collection: tangled.PublicKeyNSID,
-
Repo: did,
-
Rkey: rkey,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.PublicKey{
-
Created: time.Now().Format(time.RFC3339),
-
Key: key,
-
Name: name,
-
}},
-
})
-
// invalid record
-
if err != nil {
-
log.Printf("failed to create record: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to create record.")
-
return
-
}
-
-
log.Println("created atproto record: ", resp.Uri)
-
-
err = tx.Commit()
-
if err != nil {
-
log.Printf("failed to commit tx; adding public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, "/settings")
-
return
-
-
case http.MethodDelete:
-
did := s.auth.GetDid(r)
-
q := r.URL.Query()
-
-
name := q.Get("name")
-
rkey := q.Get("rkey")
-
key := q.Get("key")
-
-
log.Println(name)
-
log.Println(rkey)
-
log.Println(key)
-
-
client, _ := s.auth.AuthorizedClient(r)
-
-
if err := db.RemovePublicKey(s.db, did, name, key); err != nil {
-
log.Printf("removing public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to remove public key.")
-
return
-
}
-
-
if rkey != "" {
-
// remove from pds too
-
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
-
Collection: tangled.PublicKeyNSID,
-
Repo: did,
-
Rkey: rkey,
-
})
-
-
// invalid record
-
if err != nil {
-
log.Printf("failed to delete record from PDS: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
-
return
-
}
-
}
-
log.Println("deleted successfully")
-
-
s.pages.HxLocation(w, "/settings")
-
return
-
}
-
}
+2 -1
appview/state/star.go
···
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
tangled "tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
switch r.Method {
case http.MethodPost:
createdAt := time.Now().Format(time.RFC3339)
-
rkey := s.TID()
+
rkey := appview.TID()
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.FeedStarNSID,
Repo: currentUser.Did,
+5 -5
appview/state/state.go
···
clock := syntax.NewTIDClock(0)
-
pgs := pages.NewPages()
+
pgs := pages.NewPages(config.Dev)
resolver := appview.NewResolver()
···
return state, nil
}
-
func (s *State) TID() string {
-
return s.tidClock.Next().String()
+
func TID(c *syntax.TIDClock) string {
+
return c.Next().String()
}
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
···
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.KnotMemberNSID,
Repo: currentUser.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.KnotMember{
Member: memberIdent.DID.String(),
···
return
}
-
rkey := s.TID()
+
rkey := appview.TID()
repo := &db.Repo{
Did: user.Did,
Name: repoName,
+11
appview/tid.go
···
+
package appview
+
+
import (
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
var c *syntax.TIDClock = syntax.NewTIDClock(0)
+
+
func TID() string {
+
return c.Next().String()
+
}
+9 -7
docs/contributing.md
···
### message format
```
-
<service/top-level directory>: <package/path>: <short summary of change>
+
<service/top-level directory>: <affected package/directory>: <short summary of change>
-
Optional longer description, if needed. Explain what the change does and
-
why, especially if not obvious. Reference relevant issues or PRs when
-
applicable. These can be links for now since we don't auto-link
-
issues/PRs yet.
+
Optional longer description can go here, if necessary. Explain what the
+
change does and why, especially if not obvious. Reference relevant
+
issues or PRs when applicable. These can be links for now since we don't
+
auto-link issues/PRs yet.
```
Here are some examples:
···
### general notes
-
- PRs get merged as a single commit, so keep PRs small and focused. Use
-
the above guidelines for the PR title and description.
+
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
+
using `git am`. At present, there is no squashing -- so please author
+
your commits as they would appear on `master`, following the above
+
guidelines.
- Use the imperative mood in the summary line (e.g., "fix bug" not
"fixed bug" or "fixes bug").
- Try to keep the summary line under 72 characters, but we aren't too
+82
docs/knot-hosting.md
···
You should now have a running knot server! You can finalize your registration by hitting the
`initialize` button on the [/knots](/knots) page.
+
+
### custom paths
+
+
(This section applies to manual setup only. Docker users should edit the mounts
+
in `docker-compose.yml` instead.)
+
+
Right now, the database and repositories of your knot lives in `/home/git`. You
+
can move these paths if you'd like to store them in another folder. Be careful
+
when adjusting these paths:
+
+
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
+
any possible side effects. Remember to restart it once you're done.
+
* Make backups before moving in case something goes wrong.
+
* Make sure the `git` user can read and write from the new paths.
+
+
#### database
+
+
As an example, let's say the current database is at `/home/git/knotserver.db`,
+
and we want to move it to `/home/git/database/knotserver.db`.
+
+
Copy the current database to the new location. Make sure to copy the `.db-shm`
+
and `.db-wal` files if they exist.
+
+
```
+
mkdir /home/git/database
+
cp /home/git/knotserver.db* /home/git/database
+
```
+
+
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
+
the new file path (_not_ the directory):
+
+
```
+
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
+
```
+
+
#### repositories
+
+
As an example, let's say the repositories are currently in `/home/git`, and we
+
want to move them into `/home/git/repositories`.
+
+
Create the new folder, then move the existing repositories (if there are any):
+
+
```
+
mkdir /home/git/repositories
+
# move all DIDs into the new folder; these will vary for you!
+
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
+
```
+
+
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
+
to the new directory:
+
+
```
+
KNOT_REPO_SCAN_PATH=/home/git/repositories
+
```
+
+
In your SSH config (e.g. `/etc/ssh/sshd_config.d/authorized_keys_command.conf`),
+
update the `AuthorizedKeysCommand` line to use the new folder. For example:
+
+
```
+
Match User git
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -git-dir /home/git/repositories
+
AuthorizedKeysCommandUser nobody
+
```
+
+
Make sure to restart your SSH server!
+
+
#### git
+
+
The keyfetch executable takes multiple arguments to change certain paths. You
+
can view a full list by running `/usr/local/libexec/tangled-keyfetch -h`.
+
+
As an example, if you wanted to change the path to the repoguard executable,
+
you would edit your SSH config (e.g. `/etc/ssh/sshd_config.d/authorized_keys_command.conf`)
+
and update the `AuthorizedKeysCommand` line:
+
+
```
+
Match User git
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -repoguard-path /path/to/repoguard
+
AuthorizedKeysCommandUser nobody
+
```
+
+
Make sure to restart your SSH server!
+3 -3
flake.lock
···
"indigo": {
"flake": false,
"locked": {
-
"lastModified": 1738491661,
-
"narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=",
+
"lastModified": 1745333930,
+
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
"owner": "oppiliappan",
"repo": "indigo",
-
"rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71",
+
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
"type": "github"
},
"original": {
+11 -2
flake.nix
···
inherit (gitignore.lib) gitignoreSource;
in {
overlays.default = final: prev: let
-
goModHash = "sha256-2vljseczrvsl2T0P9k69ro72yU59l5fp9r/sszmXYY4=";
+
goModHash = "sha256-EilWxfqrcKDaSR5zA3ZuDSCq7V+/IfWpKPu8HWhpndA=";
buildCmdPackage = name:
final.buildGoModule {
pname = name;
···
${pkgs.air}/bin/air -c /dev/null \
-build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
-build.bin "./out/${name}.out" \
-
-build.include_ext "go,html,css"
+
-build.include_ext "go"
+
'';
+
tailwind-watcher =
+
pkgs.writeShellScriptBin "run"
+
''
+
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
'';
in {
watch-appview = {
···
watch-knotserver = {
type = "app";
program = ''${air-watcher "knotserver"}/bin/run'';
+
};
+
watch-tailwind = {
+
type = "app";
+
program = ''${tailwind-watcher}/bin/run'';
};
});
+4 -4
input.css
···
/* CommentSpecial */ .chroma .cs { color: #9ca0b0; font-style: italic }
/* CommentPreproc */ .chroma .cp { color: #9ca0b0; font-style: italic }
/* CommentPreprocFile */ .chroma .cpf { color: #9ca0b0; font-weight: bold; font-style: italic }
-
/* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: #ccd0da }
+
/* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: oklch(93.6% 0.032 17.717) }
/* GenericEmph */ .chroma .ge { font-style: italic }
/* GenericError */ .chroma .gr { color: #d20f39 }
/* GenericHeading */ .chroma .gh { color: #fe640b; font-weight: bold }
-
/* GenericInserted */ .chroma .gi { color: #40a02b; background-color: #ccd0da }
+
/* GenericInserted */ .chroma .gi { color: #40a02b; background-color: oklch(96.2% 0.044 156.743) }
/* GenericStrong */ .chroma .gs { font-weight: bold }
/* GenericSubheading */ .chroma .gu { color: #fe640b; font-weight: bold }
/* GenericTraceback */ .chroma .gt { color: #d20f39 }
···
/* CommentSpecial */ .chroma .cs { color: #6e738d; font-style: italic }
/* CommentPreproc */ .chroma .cp { color: #6e738d; font-style: italic }
/* CommentPreprocFile */ .chroma .cpf { color: #6e738d; font-weight: bold; font-style: italic }
-
/* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: #363a4f }
+
/* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: oklch(44.4% 0.177 26.899 / 0.5) }
/* GenericEmph */ .chroma .ge { font-style: italic }
/* GenericError */ .chroma .gr { color: #ed8796 }
/* GenericHeading */ .chroma .gh { color: #f5a97f; font-weight: bold }
-
/* GenericInserted */ .chroma .gi { color: #a6da95; background-color: #363a4f }
+
/* GenericInserted */ .chroma .gi { color: #a6da95; background-color: oklch(44.8% 0.119 151.328 / 0.5) }
/* GenericStrong */ .chroma .gs { font-weight: bold }
/* GenericSubheading */ .chroma .gu { color: #f5a97f; font-weight: bold }
/* GenericTraceback */ .chroma .gt { color: #ed8796 }
+1
knotserver/routes.go
···
capabilities := map[string]any{
"pull_requests": map[string]any{
+
"format_patch": true,
"patch_submissions": true,
"branch_submissions": true,
"fork_submissions": true,
+8
patchutil/interdiff.go
···
Files []*InterdiffFile
}
+
func (i *InterdiffResult) AffectedFiles() []string {
+
files := make([]string, len(i.Files))
+
for _, f := range i.Files {
+
files = append(files, f.Name)
+
}
+
return files
+
}
+
func (i *InterdiffResult) String() string {
var b strings.Builder
for _, f := range i.Files {
+1 -1
patchutil/patchutil.go
···
}
newTemp.Close()
-
cmd := exec.Command("diff", "-U", "9999", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name())
+
cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name())
output, err := cmd.CombinedOutput()
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
+1
types/capabilities.go
···
type Capabilities struct {
PullRequests struct {
+
FormatPatch bool `json:"format_patch"`
PatchSubmissions bool `json:"patch_submissions"`
BranchSubmissions bool `json:"branch_submissions"`
ForkSubmissions bool `json:"fork_submissions"`
+14
types/diff.go
···
Patch string `json:"patch"`
Diff []*gitdiff.File `json:"diff"`
}
+
+
func (d *NiceDiff) ChangedFiles() []string {
+
files := make([]string, len(d.Diff))
+
+
for i, f := range d.Diff {
+
if f.IsDelete {
+
files[i] = f.Name.Old
+
} else {
+
files[i] = f.Name.New
+
}
+
}
+
+
return files
+
}