forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

+21413 -9299
+1 -1
.air/knotserver.toml
··· 1 1 [build] 2 - cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 2 + cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 3 bin = ".bin/knot server" 4 4 root = "." 5 5
+6
.tangled/workflows/test.yml
··· 14 14 command: | 15 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 16 17 + - name: run linter 18 + environment: 19 + CGO_ENABLED: 1 20 + command: | 21 + go vet -v ./... 22 + 17 23 - name: run all tests 18 24 environment: 19 25 CGO_ENABLED: 1
+1288 -243
api/tangled/cbor_gen.go
··· 1499 1499 1500 1500 return nil 1501 1501 } 1502 - func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error { 1502 + func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error { 1503 1503 if t == nil { 1504 1504 _, err := w.Write(cbg.CborNull) 1505 1505 return err 1506 1506 } 1507 1507 1508 1508 cw := cbg.NewCborWriter(w) 1509 - fieldCount := 1 1510 1509 1511 - if t.Inputs == nil { 1512 - fieldCount-- 1510 + if _, err := cw.Write([]byte{162}); err != nil { 1511 + return err 1513 1512 } 1514 1513 1515 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1514 + // t.Lang (string) (string) 1515 + if len("lang") > 1000000 { 1516 + return xerrors.Errorf("Value in field \"lang\" was too long") 1517 + } 1518 + 1519 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil { 1520 + return err 1521 + } 1522 + if _, err := cw.WriteString(string("lang")); err != nil { 1516 1523 return err 1517 1524 } 1518 1525 1519 - // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1520 - if t.Inputs != nil { 1526 + if len(t.Lang) > 1000000 { 1527 + return xerrors.Errorf("Value in field t.Lang was too long") 1528 + } 1521 1529 1522 - if len("inputs") > 1000000 { 1523 - return xerrors.Errorf("Value in field \"inputs\" was too long") 1524 - } 1530 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil { 1531 + return err 1532 + } 1533 + if _, err := cw.WriteString(string(t.Lang)); err != nil { 1534 + return err 1535 + } 1525 1536 1526 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil { 1527 - return err 1528 - } 1529 - if _, err := cw.WriteString(string("inputs")); err != nil { 1530 - return err 1531 - } 1537 + // t.Size (int64) (int64) 1538 + if len("size") > 1000000 { 1539 + return xerrors.Errorf("Value in field \"size\" was too long") 1540 + } 1532 1541 1533 - if len(t.Inputs) > 8192 { 1534 - return xerrors.Errorf("Slice value in field t.Inputs was too long") 1535 - } 1542 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil { 1543 + return err 1544 + } 1545 + if _, err := cw.WriteString(string("size")); err != nil { 1546 + return err 1547 + } 1536 1548 1537 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil { 1549 + if t.Size >= 0 { 1550 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil { 1538 1551 return err 1539 1552 } 1540 - for _, v := range t.Inputs { 1541 - if err := v.MarshalCBOR(cw); err != nil { 1542 - return err 1543 - } 1544 - 1553 + } else { 1554 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil { 1555 + return err 1545 1556 } 1546 1557 } 1558 + 1547 1559 return nil 1548 1560 } 1549 1561 1550 - func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1551 - *t = GitRefUpdate_LangBreakdown{} 1562 + func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) { 1563 + *t = GitRefUpdate_IndividualLanguageSize{} 1552 1564 1553 1565 cr := cbg.NewCborReader(r) 1554 1566 ··· 1567 1579 } 1568 1580 1569 1581 if extra > cbg.MaxLength { 1570 - return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra) 1582 + return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra) 1571 1583 } 1572 1584 1573 1585 n := extra 1574 1586 1575 - nameBuf := make([]byte, 6) 1587 + nameBuf := make([]byte, 4) 1576 1588 for i := uint64(0); i < n; i++ { 1577 1589 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1578 1590 if err != nil { ··· 1588 1600 } 1589 1601 1590 1602 switch string(nameBuf[:nameLen]) { 1591 - // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1592 - case "inputs": 1593 - 1594 - maj, extra, err = cr.ReadHeader() 1595 - if err != nil { 1596 - return err 1597 - } 1598 - 1599 - if extra > 8192 { 1600 - return fmt.Errorf("t.Inputs: array too large (%d)", extra) 1601 - } 1603 + // t.Lang (string) (string) 1604 + case "lang": 1602 1605 1603 - if maj != cbg.MajArray { 1604 - return fmt.Errorf("expected cbor array") 1605 - } 1606 + { 1607 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1608 + if err != nil { 1609 + return err 1610 + } 1606 1611 1607 - if extra > 0 { 1608 - t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra) 1612 + t.Lang = string(sval) 1609 1613 } 1610 - 1611 - for i := 0; i < int(extra); i++ { 1612 - { 1613 - var maj byte 1614 - var extra uint64 1615 - var err error 1616 - _ = maj 1617 - _ = extra 1618 - _ = err 1619 - 1620 - { 1621 - 1622 - b, err := cr.ReadByte() 1623 - if err != nil { 1624 - return err 1625 - } 1626 - if b != cbg.CborNull[0] { 1627 - if err := cr.UnreadByte(); err != nil { 1628 - return err 1629 - } 1630 - t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize) 1631 - if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 1632 - return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 1633 - } 1634 - } 1635 - 1614 + // t.Size (int64) (int64) 1615 + case "size": 1616 + { 1617 + maj, extra, err := cr.ReadHeader() 1618 + if err != nil { 1619 + return err 1620 + } 1621 + var extraI int64 1622 + switch maj { 1623 + case cbg.MajUnsignedInt: 1624 + extraI = int64(extra) 1625 + if extraI < 0 { 1626 + return fmt.Errorf("int64 positive overflow") 1636 1627 } 1637 - 1628 + case cbg.MajNegativeInt: 1629 + extraI = int64(extra) 1630 + if extraI < 0 { 1631 + return fmt.Errorf("int64 negative overflow") 1632 + } 1633 + extraI = -1 - extraI 1634 + default: 1635 + return fmt.Errorf("wrong type for int64 field: %d", maj) 1638 1636 } 1637 + 1638 + t.Size = int64(extraI) 1639 1639 } 1640 1640 1641 1641 default: ··· 1648 1648 1649 1649 return nil 1650 1650 } 1651 - func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error { 1651 + func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error { 1652 1652 if t == nil { 1653 1653 _, err := w.Write(cbg.CborNull) 1654 1654 return err 1655 1655 } 1656 1656 1657 1657 cw := cbg.NewCborWriter(w) 1658 + fieldCount := 1 1658 1659 1659 - if _, err := cw.Write([]byte{162}); err != nil { 1660 - return err 1661 - } 1662 - 1663 - // t.Lang (string) (string) 1664 - if len("lang") > 1000000 { 1665 - return xerrors.Errorf("Value in field \"lang\" was too long") 1660 + if t.Inputs == nil { 1661 + fieldCount-- 1666 1662 } 1667 1663 1668 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil { 1669 - return err 1670 - } 1671 - if _, err := cw.WriteString(string("lang")); err != nil { 1664 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1672 1665 return err 1673 1666 } 1674 1667 1675 - if len(t.Lang) > 1000000 { 1676 - return xerrors.Errorf("Value in field t.Lang was too long") 1677 - } 1668 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1669 + if t.Inputs != nil { 1678 1670 1679 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil { 1680 - return err 1681 - } 1682 - if _, err := cw.WriteString(string(t.Lang)); err != nil { 1683 - return err 1684 - } 1671 + if len("inputs") > 1000000 { 1672 + return xerrors.Errorf("Value in field \"inputs\" was too long") 1673 + } 1685 1674 1686 - // t.Size (int64) (int64) 1687 - if len("size") > 1000000 { 1688 - return xerrors.Errorf("Value in field \"size\" was too long") 1689 - } 1675 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil { 1676 + return err 1677 + } 1678 + if _, err := cw.WriteString(string("inputs")); err != nil { 1679 + return err 1680 + } 1690 1681 1691 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil { 1692 - return err 1693 - } 1694 - if _, err := cw.WriteString(string("size")); err != nil { 1695 - return err 1696 - } 1682 + if len(t.Inputs) > 8192 { 1683 + return xerrors.Errorf("Slice value in field t.Inputs was too long") 1684 + } 1697 1685 1698 - if t.Size >= 0 { 1699 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil { 1686 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil { 1700 1687 return err 1701 1688 } 1702 - } else { 1703 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil { 1704 - return err 1689 + for _, v := range t.Inputs { 1690 + if err := v.MarshalCBOR(cw); err != nil { 1691 + return err 1692 + } 1693 + 1705 1694 } 1706 1695 } 1707 - 1708 1696 return nil 1709 1697 } 1710 1698 1711 - func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) { 1712 - *t = GitRefUpdate_IndividualLanguageSize{} 1699 + func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1700 + *t = GitRefUpdate_LangBreakdown{} 1713 1701 1714 1702 cr := cbg.NewCborReader(r) 1715 1703 ··· 1728 1716 } 1729 1717 1730 1718 if extra > cbg.MaxLength { 1731 - return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra) 1719 + return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra) 1732 1720 } 1733 1721 1734 1722 n := extra 1735 1723 1736 - nameBuf := make([]byte, 4) 1724 + nameBuf := make([]byte, 6) 1737 1725 for i := uint64(0); i < n; i++ { 1738 1726 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1739 1727 if err != nil { ··· 1749 1737 } 1750 1738 1751 1739 switch string(nameBuf[:nameLen]) { 1752 - // t.Lang (string) (string) 1753 - case "lang": 1740 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1741 + case "inputs": 1754 1742 1755 - { 1756 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 1757 - if err != nil { 1758 - return err 1759 - } 1743 + maj, extra, err = cr.ReadHeader() 1744 + if err != nil { 1745 + return err 1746 + } 1760 1747 1761 - t.Lang = string(sval) 1748 + if extra > 8192 { 1749 + return fmt.Errorf("t.Inputs: array too large (%d)", extra) 1762 1750 } 1763 - // t.Size (int64) (int64) 1764 - case "size": 1765 - { 1766 - maj, extra, err := cr.ReadHeader() 1767 - if err != nil { 1768 - return err 1769 - } 1770 - var extraI int64 1771 - switch maj { 1772 - case cbg.MajUnsignedInt: 1773 - extraI = int64(extra) 1774 - if extraI < 0 { 1775 - return fmt.Errorf("int64 positive overflow") 1776 - } 1777 - case cbg.MajNegativeInt: 1778 - extraI = int64(extra) 1779 - if extraI < 0 { 1780 - return fmt.Errorf("int64 negative overflow") 1751 + 1752 + if maj != cbg.MajArray { 1753 + return fmt.Errorf("expected cbor array") 1754 + } 1755 + 1756 + if extra > 0 { 1757 + t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra) 1758 + } 1759 + 1760 + for i := 0; i < int(extra); i++ { 1761 + { 1762 + var maj byte 1763 + var extra uint64 1764 + var err error 1765 + _ = maj 1766 + _ = extra 1767 + _ = err 1768 + 1769 + { 1770 + 1771 + b, err := cr.ReadByte() 1772 + if err != nil { 1773 + return err 1774 + } 1775 + if b != cbg.CborNull[0] { 1776 + if err := cr.UnreadByte(); err != nil { 1777 + return err 1778 + } 1779 + t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize) 1780 + if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 1781 + return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 1782 + } 1783 + } 1784 + 1781 1785 } 1782 - extraI = -1 - extraI 1783 - default: 1784 - return fmt.Errorf("wrong type for int64 field: %d", maj) 1786 + 1785 1787 } 1786 - 1787 - t.Size = int64(extraI) 1788 1788 } 1789 1789 1790 1790 default: ··· 2469 2469 2470 2470 return nil 2471 2471 } 2472 + func (t *LabelDefinition) MarshalCBOR(w io.Writer) error { 2473 + if t == nil { 2474 + _, err := w.Write(cbg.CborNull) 2475 + return err 2476 + } 2477 + 2478 + cw := cbg.NewCborWriter(w) 2479 + fieldCount := 7 2480 + 2481 + if t.Color == nil { 2482 + fieldCount-- 2483 + } 2484 + 2485 + if t.Multiple == nil { 2486 + fieldCount-- 2487 + } 2488 + 2489 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 2490 + return err 2491 + } 2492 + 2493 + // t.Name (string) (string) 2494 + if len("name") > 1000000 { 2495 + return xerrors.Errorf("Value in field \"name\" was too long") 2496 + } 2497 + 2498 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { 2499 + return err 2500 + } 2501 + if _, err := cw.WriteString(string("name")); err != nil { 2502 + return err 2503 + } 2504 + 2505 + if len(t.Name) > 1000000 { 2506 + return xerrors.Errorf("Value in field t.Name was too long") 2507 + } 2508 + 2509 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil { 2510 + return err 2511 + } 2512 + if _, err := cw.WriteString(string(t.Name)); err != nil { 2513 + return err 2514 + } 2515 + 2516 + // t.LexiconTypeID (string) (string) 2517 + if len("$type") > 1000000 { 2518 + return xerrors.Errorf("Value in field \"$type\" was too long") 2519 + } 2520 + 2521 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2522 + return err 2523 + } 2524 + if _, err := cw.WriteString(string("$type")); err != nil { 2525 + return err 2526 + } 2527 + 2528 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.label.definition"))); err != nil { 2529 + return err 2530 + } 2531 + if _, err := cw.WriteString(string("sh.tangled.label.definition")); err != nil { 2532 + return err 2533 + } 2534 + 2535 + // t.Color (string) (string) 2536 + if t.Color != nil { 2537 + 2538 + if len("color") > 1000000 { 2539 + return xerrors.Errorf("Value in field \"color\" was too long") 2540 + } 2541 + 2542 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("color"))); err != nil { 2543 + return err 2544 + } 2545 + if _, err := cw.WriteString(string("color")); err != nil { 2546 + return err 2547 + } 2548 + 2549 + if t.Color == nil { 2550 + if _, err := cw.Write(cbg.CborNull); err != nil { 2551 + return err 2552 + } 2553 + } else { 2554 + if len(*t.Color) > 1000000 { 2555 + return xerrors.Errorf("Value in field t.Color was too long") 2556 + } 2557 + 2558 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Color))); err != nil { 2559 + return err 2560 + } 2561 + if _, err := cw.WriteString(string(*t.Color)); err != nil { 2562 + return err 2563 + } 2564 + } 2565 + } 2566 + 2567 + // t.Scope ([]string) (slice) 2568 + if len("scope") > 1000000 { 2569 + return xerrors.Errorf("Value in field \"scope\" was too long") 2570 + } 2571 + 2572 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("scope"))); err != nil { 2573 + return err 2574 + } 2575 + if _, err := cw.WriteString(string("scope")); err != nil { 2576 + return err 2577 + } 2578 + 2579 + if len(t.Scope) > 8192 { 2580 + return xerrors.Errorf("Slice value in field t.Scope was too long") 2581 + } 2582 + 2583 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Scope))); err != nil { 2584 + return err 2585 + } 2586 + for _, v := range t.Scope { 2587 + if len(v) > 1000000 { 2588 + return xerrors.Errorf("Value in field v was too long") 2589 + } 2590 + 2591 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 2592 + return err 2593 + } 2594 + if _, err := cw.WriteString(string(v)); err != nil { 2595 + return err 2596 + } 2597 + 2598 + } 2599 + 2600 + // t.Multiple (bool) (bool) 2601 + if t.Multiple != nil { 2602 + 2603 + if len("multiple") > 1000000 { 2604 + return xerrors.Errorf("Value in field \"multiple\" was too long") 2605 + } 2606 + 2607 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("multiple"))); err != nil { 2608 + return err 2609 + } 2610 + if _, err := cw.WriteString(string("multiple")); err != nil { 2611 + return err 2612 + } 2613 + 2614 + if t.Multiple == nil { 2615 + if _, err := cw.Write(cbg.CborNull); err != nil { 2616 + return err 2617 + } 2618 + } else { 2619 + if err := cbg.WriteBool(w, *t.Multiple); err != nil { 2620 + return err 2621 + } 2622 + } 2623 + } 2624 + 2625 + // t.CreatedAt (string) (string) 2626 + if len("createdAt") > 1000000 { 2627 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2628 + } 2629 + 2630 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2631 + return err 2632 + } 2633 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2634 + return err 2635 + } 2636 + 2637 + if len(t.CreatedAt) > 1000000 { 2638 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2639 + } 2640 + 2641 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2642 + return err 2643 + } 2644 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2645 + return err 2646 + } 2647 + 2648 + // t.ValueType (tangled.LabelDefinition_ValueType) (struct) 2649 + if len("valueType") > 1000000 { 2650 + return xerrors.Errorf("Value in field \"valueType\" was too long") 2651 + } 2652 + 2653 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("valueType"))); err != nil { 2654 + return err 2655 + } 2656 + if _, err := cw.WriteString(string("valueType")); err != nil { 2657 + return err 2658 + } 2659 + 2660 + if err := t.ValueType.MarshalCBOR(cw); err != nil { 2661 + return err 2662 + } 2663 + return nil 2664 + } 2665 + 2666 + func (t *LabelDefinition) UnmarshalCBOR(r io.Reader) (err error) { 2667 + *t = LabelDefinition{} 2668 + 2669 + cr := cbg.NewCborReader(r) 2670 + 2671 + maj, extra, err := cr.ReadHeader() 2672 + if err != nil { 2673 + return err 2674 + } 2675 + defer func() { 2676 + if err == io.EOF { 2677 + err = io.ErrUnexpectedEOF 2678 + } 2679 + }() 2680 + 2681 + if maj != cbg.MajMap { 2682 + return fmt.Errorf("cbor input should be of type map") 2683 + } 2684 + 2685 + if extra > cbg.MaxLength { 2686 + return fmt.Errorf("LabelDefinition: map struct too large (%d)", extra) 2687 + } 2688 + 2689 + n := extra 2690 + 2691 + nameBuf := make([]byte, 9) 2692 + for i := uint64(0); i < n; i++ { 2693 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2694 + if err != nil { 2695 + return err 2696 + } 2697 + 2698 + if !ok { 2699 + // Field doesn't exist on this type, so ignore it 2700 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2701 + return err 2702 + } 2703 + continue 2704 + } 2705 + 2706 + switch string(nameBuf[:nameLen]) { 2707 + // t.Name (string) (string) 2708 + case "name": 2709 + 2710 + { 2711 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2712 + if err != nil { 2713 + return err 2714 + } 2715 + 2716 + t.Name = string(sval) 2717 + } 2718 + // t.LexiconTypeID (string) (string) 2719 + case "$type": 2720 + 2721 + { 2722 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2723 + if err != nil { 2724 + return err 2725 + } 2726 + 2727 + t.LexiconTypeID = string(sval) 2728 + } 2729 + // t.Color (string) (string) 2730 + case "color": 2731 + 2732 + { 2733 + b, err := cr.ReadByte() 2734 + if err != nil { 2735 + return err 2736 + } 2737 + if b != cbg.CborNull[0] { 2738 + if err := cr.UnreadByte(); err != nil { 2739 + return err 2740 + } 2741 + 2742 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2743 + if err != nil { 2744 + return err 2745 + } 2746 + 2747 + t.Color = (*string)(&sval) 2748 + } 2749 + } 2750 + // t.Scope ([]string) (slice) 2751 + case "scope": 2752 + 2753 + maj, extra, err = cr.ReadHeader() 2754 + if err != nil { 2755 + return err 2756 + } 2757 + 2758 + if extra > 8192 { 2759 + return fmt.Errorf("t.Scope: array too large (%d)", extra) 2760 + } 2761 + 2762 + if maj != cbg.MajArray { 2763 + return fmt.Errorf("expected cbor array") 2764 + } 2765 + 2766 + if extra > 0 { 2767 + t.Scope = make([]string, extra) 2768 + } 2769 + 2770 + for i := 0; i < int(extra); i++ { 2771 + { 2772 + var maj byte 2773 + var extra uint64 2774 + var err error 2775 + _ = maj 2776 + _ = extra 2777 + _ = err 2778 + 2779 + { 2780 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2781 + if err != nil { 2782 + return err 2783 + } 2784 + 2785 + t.Scope[i] = string(sval) 2786 + } 2787 + 2788 + } 2789 + } 2790 + // t.Multiple (bool) (bool) 2791 + case "multiple": 2792 + 2793 + { 2794 + b, err := cr.ReadByte() 2795 + if err != nil { 2796 + return err 2797 + } 2798 + if b != cbg.CborNull[0] { 2799 + if err := cr.UnreadByte(); err != nil { 2800 + return err 2801 + } 2802 + 2803 + maj, extra, err = cr.ReadHeader() 2804 + if err != nil { 2805 + return err 2806 + } 2807 + if maj != cbg.MajOther { 2808 + return fmt.Errorf("booleans must be major type 7") 2809 + } 2810 + 2811 + var val bool 2812 + switch extra { 2813 + case 20: 2814 + val = false 2815 + case 21: 2816 + val = true 2817 + default: 2818 + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 2819 + } 2820 + t.Multiple = &val 2821 + } 2822 + } 2823 + // t.CreatedAt (string) (string) 2824 + case "createdAt": 2825 + 2826 + { 2827 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2828 + if err != nil { 2829 + return err 2830 + } 2831 + 2832 + t.CreatedAt = string(sval) 2833 + } 2834 + // t.ValueType (tangled.LabelDefinition_ValueType) (struct) 2835 + case "valueType": 2836 + 2837 + { 2838 + 2839 + b, err := cr.ReadByte() 2840 + if err != nil { 2841 + return err 2842 + } 2843 + if b != cbg.CborNull[0] { 2844 + if err := cr.UnreadByte(); err != nil { 2845 + return err 2846 + } 2847 + t.ValueType = new(LabelDefinition_ValueType) 2848 + if err := t.ValueType.UnmarshalCBOR(cr); err != nil { 2849 + return xerrors.Errorf("unmarshaling t.ValueType pointer: %w", err) 2850 + } 2851 + } 2852 + 2853 + } 2854 + 2855 + default: 2856 + // Field doesn't exist on this type, so ignore it 2857 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2858 + return err 2859 + } 2860 + } 2861 + } 2862 + 2863 + return nil 2864 + } 2865 + func (t *LabelDefinition_ValueType) MarshalCBOR(w io.Writer) error { 2866 + if t == nil { 2867 + _, err := w.Write(cbg.CborNull) 2868 + return err 2869 + } 2870 + 2871 + cw := cbg.NewCborWriter(w) 2872 + fieldCount := 3 2873 + 2874 + if t.Enum == nil { 2875 + fieldCount-- 2876 + } 2877 + 2878 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 2879 + return err 2880 + } 2881 + 2882 + // t.Enum ([]string) (slice) 2883 + if t.Enum != nil { 2884 + 2885 + if len("enum") > 1000000 { 2886 + return xerrors.Errorf("Value in field \"enum\" was too long") 2887 + } 2888 + 2889 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("enum"))); err != nil { 2890 + return err 2891 + } 2892 + if _, err := cw.WriteString(string("enum")); err != nil { 2893 + return err 2894 + } 2895 + 2896 + if len(t.Enum) > 8192 { 2897 + return xerrors.Errorf("Slice value in field t.Enum was too long") 2898 + } 2899 + 2900 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Enum))); err != nil { 2901 + return err 2902 + } 2903 + for _, v := range t.Enum { 2904 + if len(v) > 1000000 { 2905 + return xerrors.Errorf("Value in field v was too long") 2906 + } 2907 + 2908 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 2909 + return err 2910 + } 2911 + if _, err := cw.WriteString(string(v)); err != nil { 2912 + return err 2913 + } 2914 + 2915 + } 2916 + } 2917 + 2918 + // t.Type (string) (string) 2919 + if len("type") > 1000000 { 2920 + return xerrors.Errorf("Value in field \"type\" was too long") 2921 + } 2922 + 2923 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("type"))); err != nil { 2924 + return err 2925 + } 2926 + if _, err := cw.WriteString(string("type")); err != nil { 2927 + return err 2928 + } 2929 + 2930 + if len(t.Type) > 1000000 { 2931 + return xerrors.Errorf("Value in field t.Type was too long") 2932 + } 2933 + 2934 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { 2935 + return err 2936 + } 2937 + if _, err := cw.WriteString(string(t.Type)); err != nil { 2938 + return err 2939 + } 2940 + 2941 + // t.Format (string) (string) 2942 + if len("format") > 1000000 { 2943 + return xerrors.Errorf("Value in field \"format\" was too long") 2944 + } 2945 + 2946 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("format"))); err != nil { 2947 + return err 2948 + } 2949 + if _, err := cw.WriteString(string("format")); err != nil { 2950 + return err 2951 + } 2952 + 2953 + if len(t.Format) > 1000000 { 2954 + return xerrors.Errorf("Value in field t.Format was too long") 2955 + } 2956 + 2957 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Format))); err != nil { 2958 + return err 2959 + } 2960 + if _, err := cw.WriteString(string(t.Format)); err != nil { 2961 + return err 2962 + } 2963 + return nil 2964 + } 2965 + 2966 + func (t *LabelDefinition_ValueType) UnmarshalCBOR(r io.Reader) (err error) { 2967 + *t = LabelDefinition_ValueType{} 2968 + 2969 + cr := cbg.NewCborReader(r) 2970 + 2971 + maj, extra, err := cr.ReadHeader() 2972 + if err != nil { 2973 + return err 2974 + } 2975 + defer func() { 2976 + if err == io.EOF { 2977 + err = io.ErrUnexpectedEOF 2978 + } 2979 + }() 2980 + 2981 + if maj != cbg.MajMap { 2982 + return fmt.Errorf("cbor input should be of type map") 2983 + } 2984 + 2985 + if extra > cbg.MaxLength { 2986 + return fmt.Errorf("LabelDefinition_ValueType: map struct too large (%d)", extra) 2987 + } 2988 + 2989 + n := extra 2990 + 2991 + nameBuf := make([]byte, 6) 2992 + for i := uint64(0); i < n; i++ { 2993 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2994 + if err != nil { 2995 + return err 2996 + } 2997 + 2998 + if !ok { 2999 + // Field doesn't exist on this type, so ignore it 3000 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3001 + return err 3002 + } 3003 + continue 3004 + } 3005 + 3006 + switch string(nameBuf[:nameLen]) { 3007 + // t.Enum ([]string) (slice) 3008 + case "enum": 3009 + 3010 + maj, extra, err = cr.ReadHeader() 3011 + if err != nil { 3012 + return err 3013 + } 3014 + 3015 + if extra > 8192 { 3016 + return fmt.Errorf("t.Enum: array too large (%d)", extra) 3017 + } 3018 + 3019 + if maj != cbg.MajArray { 3020 + return fmt.Errorf("expected cbor array") 3021 + } 3022 + 3023 + if extra > 0 { 3024 + t.Enum = make([]string, extra) 3025 + } 3026 + 3027 + for i := 0; i < int(extra); i++ { 3028 + { 3029 + var maj byte 3030 + var extra uint64 3031 + var err error 3032 + _ = maj 3033 + _ = extra 3034 + _ = err 3035 + 3036 + { 3037 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3038 + if err != nil { 3039 + return err 3040 + } 3041 + 3042 + t.Enum[i] = string(sval) 3043 + } 3044 + 3045 + } 3046 + } 3047 + // t.Type (string) (string) 3048 + case "type": 3049 + 3050 + { 3051 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3052 + if err != nil { 3053 + return err 3054 + } 3055 + 3056 + t.Type = string(sval) 3057 + } 3058 + // t.Format (string) (string) 3059 + case "format": 3060 + 3061 + { 3062 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3063 + if err != nil { 3064 + return err 3065 + } 3066 + 3067 + t.Format = string(sval) 3068 + } 3069 + 3070 + default: 3071 + // Field doesn't exist on this type, so ignore it 3072 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3073 + return err 3074 + } 3075 + } 3076 + } 3077 + 3078 + return nil 3079 + } 3080 + func (t *LabelOp) MarshalCBOR(w io.Writer) error { 3081 + if t == nil { 3082 + _, err := w.Write(cbg.CborNull) 3083 + return err 3084 + } 3085 + 3086 + cw := cbg.NewCborWriter(w) 3087 + 3088 + if _, err := cw.Write([]byte{165}); err != nil { 3089 + return err 3090 + } 3091 + 3092 + // t.Add ([]*tangled.LabelOp_Operand) (slice) 3093 + if len("add") > 1000000 { 3094 + return xerrors.Errorf("Value in field \"add\" was too long") 3095 + } 3096 + 3097 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("add"))); err != nil { 3098 + return err 3099 + } 3100 + if _, err := cw.WriteString(string("add")); err != nil { 3101 + return err 3102 + } 3103 + 3104 + if len(t.Add) > 8192 { 3105 + return xerrors.Errorf("Slice value in field t.Add was too long") 3106 + } 3107 + 3108 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Add))); err != nil { 3109 + return err 3110 + } 3111 + for _, v := range t.Add { 3112 + if err := v.MarshalCBOR(cw); err != nil { 3113 + return err 3114 + } 3115 + 3116 + } 3117 + 3118 + // t.LexiconTypeID (string) (string) 3119 + if len("$type") > 1000000 { 3120 + return xerrors.Errorf("Value in field \"$type\" was too long") 3121 + } 3122 + 3123 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 3124 + return err 3125 + } 3126 + if _, err := cw.WriteString(string("$type")); err != nil { 3127 + return err 3128 + } 3129 + 3130 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.label.op"))); err != nil { 3131 + return err 3132 + } 3133 + if _, err := cw.WriteString(string("sh.tangled.label.op")); err != nil { 3134 + return err 3135 + } 3136 + 3137 + // t.Delete ([]*tangled.LabelOp_Operand) (slice) 3138 + if len("delete") > 1000000 { 3139 + return xerrors.Errorf("Value in field \"delete\" was too long") 3140 + } 3141 + 3142 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("delete"))); err != nil { 3143 + return err 3144 + } 3145 + if _, err := cw.WriteString(string("delete")); err != nil { 3146 + return err 3147 + } 3148 + 3149 + if len(t.Delete) > 8192 { 3150 + return xerrors.Errorf("Slice value in field t.Delete was too long") 3151 + } 3152 + 3153 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Delete))); err != nil { 3154 + return err 3155 + } 3156 + for _, v := range t.Delete { 3157 + if err := v.MarshalCBOR(cw); err != nil { 3158 + return err 3159 + } 3160 + 3161 + } 3162 + 3163 + // t.Subject (string) (string) 3164 + if len("subject") > 1000000 { 3165 + return xerrors.Errorf("Value in field \"subject\" was too long") 3166 + } 3167 + 3168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 3169 + return err 3170 + } 3171 + if _, err := cw.WriteString(string("subject")); err != nil { 3172 + return err 3173 + } 3174 + 3175 + if len(t.Subject) > 1000000 { 3176 + return xerrors.Errorf("Value in field t.Subject was too long") 3177 + } 3178 + 3179 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 3180 + return err 3181 + } 3182 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 3183 + return err 3184 + } 3185 + 3186 + // t.PerformedAt (string) (string) 3187 + if len("performedAt") > 1000000 { 3188 + return xerrors.Errorf("Value in field \"performedAt\" was too long") 3189 + } 3190 + 3191 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("performedAt"))); err != nil { 3192 + return err 3193 + } 3194 + if _, err := cw.WriteString(string("performedAt")); err != nil { 3195 + return err 3196 + } 3197 + 3198 + if len(t.PerformedAt) > 1000000 { 3199 + return xerrors.Errorf("Value in field t.PerformedAt was too long") 3200 + } 3201 + 3202 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.PerformedAt))); err != nil { 3203 + return err 3204 + } 3205 + if _, err := cw.WriteString(string(t.PerformedAt)); err != nil { 3206 + return err 3207 + } 3208 + return nil 3209 + } 3210 + 3211 + func (t *LabelOp) UnmarshalCBOR(r io.Reader) (err error) { 3212 + *t = LabelOp{} 3213 + 3214 + cr := cbg.NewCborReader(r) 3215 + 3216 + maj, extra, err := cr.ReadHeader() 3217 + if err != nil { 3218 + return err 3219 + } 3220 + defer func() { 3221 + if err == io.EOF { 3222 + err = io.ErrUnexpectedEOF 3223 + } 3224 + }() 3225 + 3226 + if maj != cbg.MajMap { 3227 + return fmt.Errorf("cbor input should be of type map") 3228 + } 3229 + 3230 + if extra > cbg.MaxLength { 3231 + return fmt.Errorf("LabelOp: map struct too large (%d)", extra) 3232 + } 3233 + 3234 + n := extra 3235 + 3236 + nameBuf := make([]byte, 11) 3237 + for i := uint64(0); i < n; i++ { 3238 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3239 + if err != nil { 3240 + return err 3241 + } 3242 + 3243 + if !ok { 3244 + // Field doesn't exist on this type, so ignore it 3245 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3246 + return err 3247 + } 3248 + continue 3249 + } 3250 + 3251 + switch string(nameBuf[:nameLen]) { 3252 + // t.Add ([]*tangled.LabelOp_Operand) (slice) 3253 + case "add": 3254 + 3255 + maj, extra, err = cr.ReadHeader() 3256 + if err != nil { 3257 + return err 3258 + } 3259 + 3260 + if extra > 8192 { 3261 + return fmt.Errorf("t.Add: array too large (%d)", extra) 3262 + } 3263 + 3264 + if maj != cbg.MajArray { 3265 + return fmt.Errorf("expected cbor array") 3266 + } 3267 + 3268 + if extra > 0 { 3269 + t.Add = make([]*LabelOp_Operand, extra) 3270 + } 3271 + 3272 + for i := 0; i < int(extra); i++ { 3273 + { 3274 + var maj byte 3275 + var extra uint64 3276 + var err error 3277 + _ = maj 3278 + _ = extra 3279 + _ = err 3280 + 3281 + { 3282 + 3283 + b, err := cr.ReadByte() 3284 + if err != nil { 3285 + return err 3286 + } 3287 + if b != cbg.CborNull[0] { 3288 + if err := cr.UnreadByte(); err != nil { 3289 + return err 3290 + } 3291 + t.Add[i] = new(LabelOp_Operand) 3292 + if err := t.Add[i].UnmarshalCBOR(cr); err != nil { 3293 + return xerrors.Errorf("unmarshaling t.Add[i] pointer: %w", err) 3294 + } 3295 + } 3296 + 3297 + } 3298 + 3299 + } 3300 + } 3301 + // t.LexiconTypeID (string) (string) 3302 + case "$type": 3303 + 3304 + { 3305 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3306 + if err != nil { 3307 + return err 3308 + } 3309 + 3310 + t.LexiconTypeID = string(sval) 3311 + } 3312 + // t.Delete ([]*tangled.LabelOp_Operand) (slice) 3313 + case "delete": 3314 + 3315 + maj, extra, err = cr.ReadHeader() 3316 + if err != nil { 3317 + return err 3318 + } 3319 + 3320 + if extra > 8192 { 3321 + return fmt.Errorf("t.Delete: array too large (%d)", extra) 3322 + } 3323 + 3324 + if maj != cbg.MajArray { 3325 + return fmt.Errorf("expected cbor array") 3326 + } 3327 + 3328 + if extra > 0 { 3329 + t.Delete = make([]*LabelOp_Operand, extra) 3330 + } 3331 + 3332 + for i := 0; i < int(extra); i++ { 3333 + { 3334 + var maj byte 3335 + var extra uint64 3336 + var err error 3337 + _ = maj 3338 + _ = extra 3339 + _ = err 3340 + 3341 + { 3342 + 3343 + b, err := cr.ReadByte() 3344 + if err != nil { 3345 + return err 3346 + } 3347 + if b != cbg.CborNull[0] { 3348 + if err := cr.UnreadByte(); err != nil { 3349 + return err 3350 + } 3351 + t.Delete[i] = new(LabelOp_Operand) 3352 + if err := t.Delete[i].UnmarshalCBOR(cr); err != nil { 3353 + return xerrors.Errorf("unmarshaling t.Delete[i] pointer: %w", err) 3354 + } 3355 + } 3356 + 3357 + } 3358 + 3359 + } 3360 + } 3361 + // t.Subject (string) (string) 3362 + case "subject": 3363 + 3364 + { 3365 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3366 + if err != nil { 3367 + return err 3368 + } 3369 + 3370 + t.Subject = string(sval) 3371 + } 3372 + // t.PerformedAt (string) (string) 3373 + case "performedAt": 3374 + 3375 + { 3376 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3377 + if err != nil { 3378 + return err 3379 + } 3380 + 3381 + t.PerformedAt = string(sval) 3382 + } 3383 + 3384 + default: 3385 + // Field doesn't exist on this type, so ignore it 3386 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3387 + return err 3388 + } 3389 + } 3390 + } 3391 + 3392 + return nil 3393 + } 3394 + func (t *LabelOp_Operand) MarshalCBOR(w io.Writer) error { 3395 + if t == nil { 3396 + _, err := w.Write(cbg.CborNull) 3397 + return err 3398 + } 3399 + 3400 + cw := cbg.NewCborWriter(w) 3401 + 3402 + if _, err := cw.Write([]byte{162}); err != nil { 3403 + return err 3404 + } 3405 + 3406 + // t.Key (string) (string) 3407 + if len("key") > 1000000 { 3408 + return xerrors.Errorf("Value in field \"key\" was too long") 3409 + } 3410 + 3411 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("key"))); err != nil { 3412 + return err 3413 + } 3414 + if _, err := cw.WriteString(string("key")); err != nil { 3415 + return err 3416 + } 3417 + 3418 + if len(t.Key) > 1000000 { 3419 + return xerrors.Errorf("Value in field t.Key was too long") 3420 + } 3421 + 3422 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Key))); err != nil { 3423 + return err 3424 + } 3425 + if _, err := cw.WriteString(string(t.Key)); err != nil { 3426 + return err 3427 + } 3428 + 3429 + // t.Value (string) (string) 3430 + if len("value") > 1000000 { 3431 + return xerrors.Errorf("Value in field \"value\" was too long") 3432 + } 3433 + 3434 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("value"))); err != nil { 3435 + return err 3436 + } 3437 + if _, err := cw.WriteString(string("value")); err != nil { 3438 + return err 3439 + } 3440 + 3441 + if len(t.Value) > 1000000 { 3442 + return xerrors.Errorf("Value in field t.Value was too long") 3443 + } 3444 + 3445 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Value))); err != nil { 3446 + return err 3447 + } 3448 + if _, err := cw.WriteString(string(t.Value)); err != nil { 3449 + return err 3450 + } 3451 + return nil 3452 + } 3453 + 3454 + func (t *LabelOp_Operand) UnmarshalCBOR(r io.Reader) (err error) { 3455 + *t = LabelOp_Operand{} 3456 + 3457 + cr := cbg.NewCborReader(r) 3458 + 3459 + maj, extra, err := cr.ReadHeader() 3460 + if err != nil { 3461 + return err 3462 + } 3463 + defer func() { 3464 + if err == io.EOF { 3465 + err = io.ErrUnexpectedEOF 3466 + } 3467 + }() 3468 + 3469 + if maj != cbg.MajMap { 3470 + return fmt.Errorf("cbor input should be of type map") 3471 + } 3472 + 3473 + if extra > cbg.MaxLength { 3474 + return fmt.Errorf("LabelOp_Operand: map struct too large (%d)", extra) 3475 + } 3476 + 3477 + n := extra 3478 + 3479 + nameBuf := make([]byte, 5) 3480 + for i := uint64(0); i < n; i++ { 3481 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3482 + if err != nil { 3483 + return err 3484 + } 3485 + 3486 + if !ok { 3487 + // Field doesn't exist on this type, so ignore it 3488 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3489 + return err 3490 + } 3491 + continue 3492 + } 3493 + 3494 + switch string(nameBuf[:nameLen]) { 3495 + // t.Key (string) (string) 3496 + case "key": 3497 + 3498 + { 3499 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3500 + if err != nil { 3501 + return err 3502 + } 3503 + 3504 + t.Key = string(sval) 3505 + } 3506 + // t.Value (string) (string) 3507 + case "value": 3508 + 3509 + { 3510 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3511 + if err != nil { 3512 + return err 3513 + } 3514 + 3515 + t.Value = string(sval) 3516 + } 3517 + 3518 + default: 3519 + // Field doesn't exist on this type, so ignore it 3520 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3521 + return err 3522 + } 3523 + } 3524 + } 3525 + 3526 + return nil 3527 + } 2472 3528 func (t *Pipeline) MarshalCBOR(w io.Writer) error { 2473 3529 if t == nil { 2474 3530 _, err := w.Write(cbg.CborNull) ··· 4756 5812 fieldCount-- 4757 5813 } 4758 5814 5815 + if t.Labels == nil { 5816 + fieldCount-- 5817 + } 5818 + 4759 5819 if t.Source == nil { 4760 5820 fieldCount-- 4761 5821 } ··· 4833 5893 return err 4834 5894 } 4835 5895 4836 - // t.Owner (string) (string) 4837 - if len("owner") > 1000000 { 4838 - return xerrors.Errorf("Value in field \"owner\" was too long") 4839 - } 5896 + // t.Labels ([]string) (slice) 5897 + if t.Labels != nil { 5898 + 5899 + if len("labels") > 1000000 { 5900 + return xerrors.Errorf("Value in field \"labels\" was too long") 5901 + } 5902 + 5903 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("labels"))); err != nil { 5904 + return err 5905 + } 5906 + if _, err := cw.WriteString(string("labels")); err != nil { 5907 + return err 5908 + } 4840 5909 4841 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 4842 - return err 4843 - } 4844 - if _, err := cw.WriteString(string("owner")); err != nil { 4845 - return err 4846 - } 5910 + if len(t.Labels) > 8192 { 5911 + return xerrors.Errorf("Slice value in field t.Labels was too long") 5912 + } 5913 + 5914 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Labels))); err != nil { 5915 + return err 5916 + } 5917 + for _, v := range t.Labels { 5918 + if len(v) > 1000000 { 5919 + return xerrors.Errorf("Value in field v was too long") 5920 + } 4847 5921 4848 - if len(t.Owner) > 1000000 { 4849 - return xerrors.Errorf("Value in field t.Owner was too long") 4850 - } 5922 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 5923 + return err 5924 + } 5925 + if _, err := cw.WriteString(string(v)); err != nil { 5926 + return err 5927 + } 4851 5928 4852 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil { 4853 - return err 4854 - } 4855 - if _, err := cw.WriteString(string(t.Owner)); err != nil { 4856 - return err 5929 + } 4857 5930 } 4858 5931 4859 5932 // t.Source (string) (string) ··· 5051 6124 5052 6125 t.LexiconTypeID = string(sval) 5053 6126 } 5054 - // t.Owner (string) (string) 5055 - case "owner": 6127 + // t.Labels ([]string) (slice) 6128 + case "labels": 6129 + 6130 + maj, extra, err = cr.ReadHeader() 6131 + if err != nil { 6132 + return err 6133 + } 6134 + 6135 + if extra > 8192 { 6136 + return fmt.Errorf("t.Labels: array too large (%d)", extra) 6137 + } 6138 + 6139 + if maj != cbg.MajArray { 6140 + return fmt.Errorf("expected cbor array") 6141 + } 6142 + 6143 + if extra > 0 { 6144 + t.Labels = make([]string, extra) 6145 + } 6146 + 6147 + for i := 0; i < int(extra); i++ { 6148 + { 6149 + var maj byte 6150 + var extra uint64 6151 + var err error 6152 + _ = maj 6153 + _ = extra 6154 + _ = err 5056 6155 5057 - { 5058 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 5059 - if err != nil { 5060 - return err 6156 + { 6157 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6158 + if err != nil { 6159 + return err 6160 + } 6161 + 6162 + t.Labels[i] = string(sval) 6163 + } 6164 + 5061 6165 } 5062 - 5063 - t.Owner = string(sval) 5064 6166 } 5065 6167 // t.Source (string) (string) 5066 6168 case "source": ··· 5898 7000 } 5899 7001 5900 7002 cw := cbg.NewCborWriter(w) 5901 - fieldCount := 6 5902 - 5903 - if t.Owner == nil { 5904 - fieldCount-- 5905 - } 7003 + fieldCount := 5 5906 7004 5907 - if t.Repo == nil { 7005 + if t.ReplyTo == nil { 5908 7006 fieldCount-- 5909 7007 } 5910 7008 ··· 5935 7033 return err 5936 7034 } 5937 7035 5938 - // t.Repo (string) (string) 5939 - if t.Repo != nil { 5940 - 5941 - if len("repo") > 1000000 { 5942 - return xerrors.Errorf("Value in field \"repo\" was too long") 5943 - } 5944 - 5945 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5946 - return err 5947 - } 5948 - if _, err := cw.WriteString(string("repo")); err != nil { 5949 - return err 5950 - } 5951 - 5952 - if t.Repo == nil { 5953 - if _, err := cw.Write(cbg.CborNull); err != nil { 5954 - return err 5955 - } 5956 - } else { 5957 - if len(*t.Repo) > 1000000 { 5958 - return xerrors.Errorf("Value in field t.Repo was too long") 5959 - } 5960 - 5961 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 5962 - return err 5963 - } 5964 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 5965 - return err 5966 - } 5967 - } 5968 - } 5969 - 5970 7036 // t.LexiconTypeID (string) (string) 5971 7037 if len("$type") > 1000000 { 5972 7038 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6009 7075 return err 6010 7076 } 6011 7077 6012 - // t.Owner (string) (string) 6013 - if t.Owner != nil { 7078 + // t.ReplyTo (string) (string) 7079 + if t.ReplyTo != nil { 6014 7080 6015 - if len("owner") > 1000000 { 6016 - return xerrors.Errorf("Value in field \"owner\" was too long") 7081 + if len("replyTo") > 1000000 { 7082 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 6017 7083 } 6018 7084 6019 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 7085 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 6020 7086 return err 6021 7087 } 6022 - if _, err := cw.WriteString(string("owner")); err != nil { 7088 + if _, err := cw.WriteString(string("replyTo")); err != nil { 6023 7089 return err 6024 7090 } 6025 7091 6026 - if t.Owner == nil { 7092 + if t.ReplyTo == nil { 6027 7093 if _, err := cw.Write(cbg.CborNull); err != nil { 6028 7094 return err 6029 7095 } 6030 7096 } else { 6031 - if len(*t.Owner) > 1000000 { 6032 - return xerrors.Errorf("Value in field t.Owner was too long") 7097 + if len(*t.ReplyTo) > 1000000 { 7098 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 6033 7099 } 6034 7100 6035 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 7101 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 6036 7102 return err 6037 7103 } 6038 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 7104 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 6039 7105 return err 6040 7106 } 6041 7107 } ··· 6118 7184 6119 7185 t.Body = string(sval) 6120 7186 } 6121 - // t.Repo (string) (string) 6122 - case "repo": 6123 - 6124 - { 6125 - b, err := cr.ReadByte() 6126 - if err != nil { 6127 - return err 6128 - } 6129 - if b != cbg.CborNull[0] { 6130 - if err := cr.UnreadByte(); err != nil { 6131 - return err 6132 - } 6133 - 6134 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6135 - if err != nil { 6136 - return err 6137 - } 6138 - 6139 - t.Repo = (*string)(&sval) 6140 - } 6141 - } 6142 7187 // t.LexiconTypeID (string) (string) 6143 7188 case "$type": 6144 7189 ··· 6161 7206 6162 7207 t.Issue = string(sval) 6163 7208 } 6164 - // t.Owner (string) (string) 6165 - case "owner": 7209 + // t.ReplyTo (string) (string) 7210 + case "replyTo": 6166 7211 6167 7212 { 6168 7213 b, err := cr.ReadByte() ··· 6179 7224 return err 6180 7225 } 6181 7226 6182 - t.Owner = (*string)(&sval) 7227 + t.ReplyTo = (*string)(&sval) 6183 7228 } 6184 7229 } 6185 7230 // t.CreatedAt (string) (string)
+1 -2
api/tangled/issuecomment.go
··· 21 21 Body string `json:"body" cborgen:"body"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 23 Issue string `json:"issue" cborgen:"issue"` 24 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 25 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 24 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 26 25 }
+53
api/tangled/knotlistKeys.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.listKeys 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotListKeysNSID = "sh.tangled.knot.listKeys" 15 + ) 16 + 17 + // KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call. 18 + type KnotListKeys_Output struct { 19 + // cursor: Pagination cursor for next page 20 + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` 21 + Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"` 22 + } 23 + 24 + // KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema. 25 + type KnotListKeys_PublicKey struct { 26 + // createdAt: Key upload timestamp 27 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 28 + // did: DID associated with the public key 29 + Did string `json:"did" cborgen:"did"` 30 + // key: Public key contents 31 + Key string `json:"key" cborgen:"key"` 32 + } 33 + 34 + // KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys". 35 + // 36 + // cursor: Pagination cursor 37 + // limit: Maximum number of keys to return 38 + func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) { 39 + var out KnotListKeys_Output 40 + 41 + params := map[string]interface{}{} 42 + if cursor != "" { 43 + params["cursor"] = cursor 44 + } 45 + if limit != 0 { 46 + params["limit"] = limit 47 + } 48 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil { 49 + return nil, err 50 + } 51 + 52 + return &out, nil 53 + }
+30
api/tangled/knotversion.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.version 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotVersionNSID = "sh.tangled.knot.version" 15 + ) 16 + 17 + // KnotVersion_Output is the output of a sh.tangled.knot.version call. 18 + type KnotVersion_Output struct { 19 + Version string `json:"version" cborgen:"version"` 20 + } 21 + 22 + // KnotVersion calls the XRPC method "sh.tangled.knot.version". 23 + func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) { 24 + var out KnotVersion_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+42
api/tangled/labeldefinition.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.label.definition 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + LabelDefinitionNSID = "sh.tangled.label.definition" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.label.definition", &LabelDefinition{}) 17 + } // 18 + // RECORDTYPE: LabelDefinition 19 + type LabelDefinition struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.label.definition" cborgen:"$type,const=sh.tangled.label.definition"` 21 + // color: The hex value for the background color for the label. Appviews may choose to respect this. 22 + Color *string `json:"color,omitempty" cborgen:"color,omitempty"` 23 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 + // multiple: Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar] 25 + Multiple *bool `json:"multiple,omitempty" cborgen:"multiple,omitempty"` 26 + // name: The display name of this label. 27 + Name string `json:"name" cborgen:"name"` 28 + // scope: The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this. 29 + Scope []string `json:"scope" cborgen:"scope"` 30 + // valueType: The type definition of this label. Appviews may allow sorting for certain types. 31 + ValueType *LabelDefinition_ValueType `json:"valueType" cborgen:"valueType"` 32 + } 33 + 34 + // LabelDefinition_ValueType is a "valueType" in the sh.tangled.label.definition schema. 35 + type LabelDefinition_ValueType struct { 36 + // enum: Closed set of values that this label can take. 37 + Enum []string `json:"enum,omitempty" cborgen:"enum,omitempty"` 38 + // format: An optional constraint that can be applied on string concrete types. 39 + Format string `json:"format" cborgen:"format"` 40 + // type: The concrete type of this label's value. 41 + Type string `json:"type" cborgen:"type"` 42 + }
+34
api/tangled/labelop.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.label.op 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + LabelOpNSID = "sh.tangled.label.op" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.label.op", &LabelOp{}) 17 + } // 18 + // RECORDTYPE: LabelOp 19 + type LabelOp struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.label.op" cborgen:"$type,const=sh.tangled.label.op"` 21 + Add []*LabelOp_Operand `json:"add" cborgen:"add"` 22 + Delete []*LabelOp_Operand `json:"delete" cborgen:"delete"` 23 + PerformedAt string `json:"performedAt" cborgen:"performedAt"` 24 + // subject: The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op. 25 + Subject string `json:"subject" cborgen:"subject"` 26 + } 27 + 28 + // LabelOp_Operand is a "operand" in the sh.tangled.label.op schema. 29 + type LabelOp_Operand struct { 30 + // key: ATURI to the label definition 31 + Key string `json:"key" cborgen:"key"` 32 + // value: Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value. 33 + Value string `json:"value" cborgen:"value"` 34 + }
+41
api/tangled/repoarchive.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.archive 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoArchiveNSID = "sh.tangled.repo.archive" 16 + ) 17 + 18 + // RepoArchive calls the XRPC method "sh.tangled.repo.archive". 19 + // 20 + // format: Archive format 21 + // prefix: Prefix for files in the archive 22 + // ref: Git reference (branch, tag, or commit SHA) 23 + // repo: Repository identifier in format 'did:plc:.../repoName' 24 + func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) { 25 + buf := new(bytes.Buffer) 26 + 27 + params := map[string]interface{}{} 28 + if format != "" { 29 + params["format"] = format 30 + } 31 + if prefix != "" { 32 + params["prefix"] = prefix 33 + } 34 + params["ref"] = ref 35 + params["repo"] = repo 36 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil { 37 + return nil, err 38 + } 39 + 40 + return buf.Bytes(), nil 41 + }
+80
api/tangled/repoblob.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.blob 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBlobNSID = "sh.tangled.repo.blob" 15 + ) 16 + 17 + // RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema. 18 + type RepoBlob_LastCommit struct { 19 + Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Commit hash 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Commit message 23 + Message string `json:"message" cborgen:"message"` 24 + // shortHash: Short commit hash 25 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 26 + // when: Commit timestamp 27 + When string `json:"when" cborgen:"when"` 28 + } 29 + 30 + // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 + type RepoBlob_Output struct { 32 + // content: File content (base64 encoded for binary files) 33 + Content string `json:"content" cborgen:"content"` 34 + // encoding: Content encoding 35 + Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 + // isBinary: Whether the file is binary 37 + IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"` 38 + LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"` 39 + // mimeType: MIME type of the file 40 + MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"` 41 + // path: The file path 42 + Path string `json:"path" cborgen:"path"` 43 + // ref: The git reference used 44 + Ref string `json:"ref" cborgen:"ref"` 45 + // size: File size in bytes 46 + Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 + } 48 + 49 + // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. 50 + type RepoBlob_Signature struct { 51 + // email: Author email 52 + Email string `json:"email" cborgen:"email"` 53 + // name: Author name 54 + Name string `json:"name" cborgen:"name"` 55 + // when: Author timestamp 56 + When string `json:"when" cborgen:"when"` 57 + } 58 + 59 + // RepoBlob calls the XRPC method "sh.tangled.repo.blob". 60 + // 61 + // path: Path to the file within the repository 62 + // raw: Return raw file content instead of JSON response 63 + // ref: Git reference (branch, tag, or commit SHA) 64 + // repo: Repository identifier in format 'did:plc:.../repoName' 65 + func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) { 66 + var out RepoBlob_Output 67 + 68 + params := map[string]interface{}{} 69 + params["path"] = path 70 + if raw { 71 + params["raw"] = raw 72 + } 73 + params["ref"] = ref 74 + params["repo"] = repo 75 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil { 76 + return nil, err 77 + } 78 + 79 + return &out, nil 80 + }
+59
api/tangled/repobranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBranchNSID = "sh.tangled.repo.branch" 15 + ) 16 + 17 + // RepoBranch_Output is the output of a sh.tangled.repo.branch call. 18 + type RepoBranch_Output struct { 19 + Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on this branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // isDefault: Whether this is the default branch 23 + IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"` 24 + // message: Latest commit message 25 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 26 + // name: Branch name 27 + Name string `json:"name" cborgen:"name"` 28 + // shortHash: Short commit hash 29 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 30 + // when: Timestamp of latest commit 31 + When string `json:"when" cborgen:"when"` 32 + } 33 + 34 + // RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema. 35 + type RepoBranch_Signature struct { 36 + // email: Author email 37 + Email string `json:"email" cborgen:"email"` 38 + // name: Author name 39 + Name string `json:"name" cborgen:"name"` 40 + // when: Author timestamp 41 + When string `json:"when" cborgen:"when"` 42 + } 43 + 44 + // RepoBranch calls the XRPC method "sh.tangled.repo.branch". 45 + // 46 + // name: Branch name to get information for 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) { 49 + var out RepoBranch_Output 50 + 51 + params := map[string]interface{}{} 52 + params["name"] = name 53 + params["repo"] = repo 54 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil { 55 + return nil, err 56 + } 57 + 58 + return &out, nil 59 + }
+39
api/tangled/repobranches.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branches 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoBranchesNSID = "sh.tangled.repo.branches" 16 + ) 17 + 18 + // RepoBranches calls the XRPC method "sh.tangled.repo.branches". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of branches to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+35
api/tangled/repocompare.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.compare 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoCompareNSID = "sh.tangled.repo.compare" 16 + ) 17 + 18 + // RepoCompare calls the XRPC method "sh.tangled.repo.compare". 19 + // 20 + // repo: Repository identifier in format 'did:plc:.../repoName' 21 + // rev1: First revision (commit, branch, or tag) 22 + // rev2: Second revision (commit, branch, or tag) 23 + func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + params["repo"] = repo 28 + params["rev1"] = rev1 29 + params["rev2"] = rev2 30 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil { 31 + return nil, err 32 + } 33 + 34 + return buf.Bytes(), nil 35 + }
+30
api/tangled/repodeleteBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.deleteBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch" 15 + ) 16 + 17 + // RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call. 18 + type RepoDeleteBranch_Input struct { 19 + Branch string `json:"branch" cborgen:"branch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch". 24 + func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+33
api/tangled/repodiff.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.diff 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoDiffNSID = "sh.tangled.repo.diff" 16 + ) 17 + 18 + // RepoDiff calls the XRPC method "sh.tangled.repo.diff". 19 + // 20 + // ref: Git reference (branch, tag, or commit SHA) 21 + // repo: Repository identifier in format 'did:plc:.../repoName' 22 + func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) { 23 + buf := new(bytes.Buffer) 24 + 25 + params := map[string]interface{}{} 26 + params["ref"] = ref 27 + params["repo"] = repo 28 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil { 29 + return nil, err 30 + } 31 + 32 + return buf.Bytes(), nil 33 + }
+55
api/tangled/repogetDefaultBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.getDefaultBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch" 15 + ) 16 + 17 + // RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call. 18 + type RepoGetDefaultBranch_Output struct { 19 + Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on default branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Latest commit message 23 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 24 + // name: Default branch name 25 + Name string `json:"name" cborgen:"name"` 26 + // shortHash: Short commit hash 27 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 28 + // when: Timestamp of latest commit 29 + When string `json:"when" cborgen:"when"` 30 + } 31 + 32 + // RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema. 33 + type RepoGetDefaultBranch_Signature struct { 34 + // email: Author email 35 + Email string `json:"email" cborgen:"email"` 36 + // name: Author name 37 + Name string `json:"name" cborgen:"name"` 38 + // when: Author timestamp 39 + When string `json:"when" cborgen:"when"` 40 + } 41 + 42 + // RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch". 43 + // 44 + // repo: Repository identifier in format 'did:plc:.../repoName' 45 + func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) { 46 + var out RepoGetDefaultBranch_Output 47 + 48 + params := map[string]interface{}{} 49 + params["repo"] = repo 50 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil { 51 + return nil, err 52 + } 53 + 54 + return &out, nil 55 + }
+61
api/tangled/repolanguages.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.languages 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoLanguagesNSID = "sh.tangled.repo.languages" 15 + ) 16 + 17 + // RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema. 18 + type RepoLanguages_Language struct { 19 + // color: Hex color code for this language 20 + Color *string `json:"color,omitempty" cborgen:"color,omitempty"` 21 + // extensions: File extensions associated with this language 22 + Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"` 23 + // fileCount: Number of files in this language 24 + FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"` 25 + // name: Programming language name 26 + Name string `json:"name" cborgen:"name"` 27 + // percentage: Percentage of total codebase (0-100) 28 + Percentage int64 `json:"percentage" cborgen:"percentage"` 29 + // size: Total size of files in this language (bytes) 30 + Size int64 `json:"size" cborgen:"size"` 31 + } 32 + 33 + // RepoLanguages_Output is the output of a sh.tangled.repo.languages call. 34 + type RepoLanguages_Output struct { 35 + Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"` 36 + // ref: The git reference used 37 + Ref string `json:"ref" cborgen:"ref"` 38 + // totalFiles: Total number of files analyzed 39 + TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"` 40 + // totalSize: Total size of all analyzed files in bytes 41 + TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"` 42 + } 43 + 44 + // RepoLanguages calls the XRPC method "sh.tangled.repo.languages". 45 + // 46 + // ref: Git reference (branch, tag, or commit SHA) 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) { 49 + var out RepoLanguages_Output 50 + 51 + params := map[string]interface{}{} 52 + if ref != "" { 53 + params["ref"] = ref 54 + } 55 + params["repo"] = repo 56 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil { 57 + return nil, err 58 + } 59 + 60 + return &out, nil 61 + }
+45
api/tangled/repolog.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.log 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoLogNSID = "sh.tangled.repo.log" 16 + ) 17 + 18 + // RepoLog calls the XRPC method "sh.tangled.repo.log". 19 + // 20 + // cursor: Pagination cursor (commit SHA) 21 + // limit: Maximum number of commits to return 22 + // path: Path to filter commits by 23 + // ref: Git reference (branch, tag, or commit SHA) 24 + // repo: Repository identifier in format 'did:plc:.../repoName' 25 + func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) { 26 + buf := new(bytes.Buffer) 27 + 28 + params := map[string]interface{}{} 29 + if cursor != "" { 30 + params["cursor"] = cursor 31 + } 32 + if limit != 0 { 33 + params["limit"] = limit 34 + } 35 + if path != "" { 36 + params["path"] = path 37 + } 38 + params["ref"] = ref 39 + params["repo"] = repo 40 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil { 41 + return nil, err 42 + } 43 + 44 + return buf.Bytes(), nil 45 + }
+39
api/tangled/repotags.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tags 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoTagsNSID = "sh.tangled.repo.tags" 16 + ) 17 + 18 + // RepoTags calls the XRPC method "sh.tangled.repo.tags". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of tags to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tags", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+82
api/tangled/repotree.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tree 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoTreeNSID = "sh.tangled.repo.tree" 15 + ) 16 + 17 + // RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema. 18 + type RepoTree_LastCommit struct { 19 + // hash: Commit hash 20 + Hash string `json:"hash" cborgen:"hash"` 21 + // message: Commit message 22 + Message string `json:"message" cborgen:"message"` 23 + // when: Commit timestamp 24 + When string `json:"when" cborgen:"when"` 25 + } 26 + 27 + // RepoTree_Output is the output of a sh.tangled.repo.tree call. 28 + type RepoTree_Output struct { 29 + // dotdot: Parent directory path 30 + Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 31 + Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 + // parent: The parent path in the tree 33 + Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // readme: Readme for this file tree 35 + Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"` 36 + // ref: The git reference used 37 + Ref string `json:"ref" cborgen:"ref"` 38 + } 39 + 40 + // RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema. 41 + type RepoTree_Readme struct { 42 + // contents: Contents of the readme file 43 + Contents string `json:"contents" cborgen:"contents"` 44 + // filename: Name of the readme file 45 + Filename string `json:"filename" cborgen:"filename"` 46 + } 47 + 48 + // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 49 + type RepoTree_TreeEntry struct { 50 + // is_file: Whether this entry is a file 51 + Is_file bool `json:"is_file" cborgen:"is_file"` 52 + // is_subtree: Whether this entry is a directory/subtree 53 + Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"` 54 + Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 55 + // mode: File mode 56 + Mode string `json:"mode" cborgen:"mode"` 57 + // name: Relative file or directory name 58 + Name string `json:"name" cborgen:"name"` 59 + // size: File size in bytes 60 + Size int64 `json:"size" cborgen:"size"` 61 + } 62 + 63 + // RepoTree calls the XRPC method "sh.tangled.repo.tree". 64 + // 65 + // path: Path within the repository tree 66 + // ref: Git reference (branch, tag, or commit SHA) 67 + // repo: Repository identifier in format 'did:plc:.../repoName' 68 + func RepoTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*RepoTree_Output, error) { 69 + var out RepoTree_Output 70 + 71 + params := map[string]interface{}{} 72 + if path != "" { 73 + params["path"] = path 74 + } 75 + params["ref"] = ref 76 + params["repo"] = repo 77 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tree", params, nil, &out); err != nil { 78 + return nil, err 79 + } 80 + 81 + return &out, nil 82 + }
+30
api/tangled/tangledowner.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.owner 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + OwnerNSID = "sh.tangled.owner" 15 + ) 16 + 17 + // Owner_Output is the output of a sh.tangled.owner call. 18 + type Owner_Output struct { 19 + Owner string `json:"owner" cborgen:"owner"` 20 + } 21 + 22 + // Owner calls the XRPC method "sh.tangled.owner". 23 + func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) { 24 + var out Owner_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+3 -2
api/tangled/tangledrepo.go
··· 22 22 Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 23 23 // knot: knot where the repo was created 24 24 Knot string `json:"knot" cborgen:"knot"` 25 + // labels: List of labels that this repo subscribes to 26 + Labels []string `json:"labels,omitempty" cborgen:"labels,omitempty"` 25 27 // name: name of the repo 26 - Name string `json:"name" cborgen:"name"` 27 - Owner string `json:"owner" cborgen:"owner"` 28 + Name string `json:"name" cborgen:"name"` 28 29 // source: source of the repo 29 30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 30 31 // spindle: CI runner to send jobs to and receive results from
+1 -1
appview/cache/session/store.go
··· 6 6 "fmt" 7 7 "time" 8 8 9 - "tangled.sh/tangled.sh/core/appview/cache" 9 + "tangled.org/core/appview/cache" 10 10 ) 11 11 12 12 type OAuthSession struct {
+5 -4
appview/commitverify/verify.go
··· 4 4 "log" 5 5 6 6 "github.com/go-git/go-git/v5/plumbing/object" 7 - "tangled.sh/tangled.sh/core/appview/db" 8 - "tangled.sh/tangled.sh/core/crypto" 9 - "tangled.sh/tangled.sh/core/types" 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/crypto" 10 + "tangled.org/core/types" 10 11 ) 11 12 12 13 type verifiedCommit struct { ··· 45 46 func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) { 46 47 vcs := VerifiedCommits{} 47 48 48 - didPubkeyCache := make(map[string][]db.PublicKey) 49 + didPubkeyCache := make(map[string][]models.PublicKey) 49 50 50 51 for _, commit := range ndCommits { 51 52 c := commit.Commit
+4 -2
appview/config/config.go
··· 72 72 } 73 73 74 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 78 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 77 79 } 78 80 79 81 func (cfg RedisConfig) ToURL() string {
+5 -25
appview/db/artifact.go
··· 5 5 "strings" 6 6 "time" 7 7 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 8 "github.com/go-git/go-git/v5/plumbing" 10 9 "github.com/ipfs/go-cid" 11 - "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.org/core/appview/models" 12 11 ) 13 12 14 - type Artifact struct { 15 - Id uint64 16 - Did string 17 - Rkey string 18 - 19 - RepoAt syntax.ATURI 20 - Tag plumbing.Hash 21 - CreatedAt time.Time 22 - 23 - BlobCid cid.Cid 24 - Name string 25 - Size uint64 26 - MimeType string 27 - } 28 - 29 - func (a *Artifact) ArtifactAt() syntax.ATURI { 30 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey)) 31 - } 32 - 33 - func AddArtifact(e Execer, artifact Artifact) error { 13 + func AddArtifact(e Execer, artifact models.Artifact) error { 34 14 _, err := e.Exec( 35 15 `insert or ignore into artifacts ( 36 16 did, ··· 57 37 return err 58 38 } 59 39 60 - func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) { 61 - var artifacts []Artifact 40 + func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) { 41 + var artifacts []models.Artifact 62 42 63 43 var conditions []string 64 44 var args []any ··· 94 74 defer rows.Close() 95 75 96 76 for rows.Next() { 97 - var artifact Artifact 77 + var artifact models.Artifact 98 78 var createdAt string 99 79 var tag []byte 100 80 var blobCid string
+3 -18
appview/db/collaborators.go
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 - "time" 7 6 8 - "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/appview/models" 9 8 ) 10 9 11 - type Collaborator struct { 12 - // identifiers for the record 13 - Id int64 14 - Did syntax.DID 15 - Rkey string 16 - 17 - // content 18 - SubjectDid syntax.DID 19 - RepoAt syntax.ATURI 20 - 21 - // meta 22 - Created time.Time 23 - } 24 - 25 - func AddCollaborator(e Execer, c Collaborator) error { 10 + func AddCollaborator(e Execer, c models.Collaborator) error { 26 11 _, err := e.Exec( 27 12 `insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`, 28 13 c.Did, c.Rkey, c.SubjectDid, c.RepoAt, ··· 49 34 return err 50 35 } 51 36 52 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 37 + func CollaboratingIn(e Execer, collaborator string) ([]models.Repo, error) { 53 38 rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 54 39 if err != nil { 55 40 return nil, err
+409 -9
appview/db/db.go
··· 466 466 primary key (did, rkey) 467 467 ); 468 468 469 + create table if not exists label_definitions ( 470 + -- identifiers 471 + id integer primary key autoincrement, 472 + did text not null, 473 + rkey text not null, 474 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.label.definition' || '/' || rkey) stored, 475 + 476 + -- content 477 + name text not null, 478 + value_type text not null check (value_type in ( 479 + "null", 480 + "boolean", 481 + "integer", 482 + "string" 483 + )), 484 + value_format text not null default "any", 485 + value_enum text, -- comma separated list 486 + scope text not null, -- comma separated list of nsid 487 + color text, 488 + multiple integer not null default 0, 489 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 490 + 491 + -- constraints 492 + unique (did, rkey) 493 + unique (at_uri) 494 + ); 495 + 496 + -- ops are flattened, a record may contain several additions and deletions, but the table will include one row per add/del 497 + create table if not exists label_ops ( 498 + -- identifiers 499 + id integer primary key autoincrement, 500 + did text not null, 501 + rkey text not null, 502 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.label.op' || '/' || rkey) stored, 503 + 504 + -- content 505 + subject text not null, 506 + operation text not null check (operation in ("add", "del")), 507 + operand_key text not null, 508 + operand_value text not null, 509 + -- we need two time values: performed is declared by the user, indexed is calculated by the av 510 + performed text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 511 + indexed text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 512 + 513 + -- constraints 514 + -- traditionally (did, rkey) pair should be unique, but not in this case 515 + -- operand_key should reference a label definition 516 + foreign key (operand_key) references label_definitions (at_uri) on delete cascade, 517 + unique (did, rkey, subject, operand_key, operand_value) 518 + ); 519 + 520 + create table if not exists repo_labels ( 521 + -- identifiers 522 + id integer primary key autoincrement, 523 + 524 + -- repo identifiers 525 + repo_at text not null, 526 + 527 + -- label to subscribe to 528 + label_at text not null, 529 + 530 + unique (repo_at, label_at) 531 + ); 532 + 533 + create table if not exists notifications ( 534 + id integer primary key autoincrement, 535 + recipient_did text not null, 536 + actor_did text not null, 537 + type text not null, 538 + entity_type text not null, 539 + entity_id text not null, 540 + read integer not null default 0, 541 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 542 + repo_id integer references repos(id), 543 + issue_id integer references issues(id), 544 + pull_id integer references pulls(id) 545 + ); 546 + 547 + create table if not exists notification_preferences ( 548 + id integer primary key autoincrement, 549 + user_did text not null unique, 550 + repo_starred integer not null default 1, 551 + issue_created integer not null default 1, 552 + issue_commented integer not null default 1, 553 + pull_created integer not null default 1, 554 + pull_commented integer not null default 1, 555 + followed integer not null default 1, 556 + pull_merged integer not null default 1, 557 + issue_closed integer not null default 1, 558 + email_notifications integer not null default 0 559 + ); 560 + 469 561 create table if not exists migrations ( 470 562 id integer primary key autoincrement, 471 563 name text unique 472 564 ); 473 565 474 - -- indexes for better star query performance 566 + -- indexes for better performance 567 + create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 568 + create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 475 569 create index if not exists idx_stars_created on stars(created); 476 570 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 477 571 `) ··· 604 698 }) 605 699 conn.ExecContext(ctx, "pragma foreign_keys = on;") 606 700 607 - // run migrations 608 701 runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 609 702 tx.Exec(` 610 703 alter table repos add column spindle text; ··· 703 796 return err 704 797 }) 705 798 799 + // repurpose the read-only column to "needs-upgrade" 800 + runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 801 + _, err := tx.Exec(` 802 + alter table registrations rename column read_only to needs_upgrade; 803 + `) 804 + return err 805 + }) 806 + 807 + // require all knots to upgrade after the release of total xrpc 808 + runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 809 + _, err := tx.Exec(` 810 + update registrations set needs_upgrade = 1; 811 + `) 812 + return err 813 + }) 814 + 815 + // require all knots to upgrade after the release of total xrpc 816 + runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 817 + _, err := tx.Exec(` 818 + alter table spindles add column needs_upgrade integer not null default 0; 819 + `) 820 + return err 821 + }) 822 + 823 + // remove issue_at from issues and replace with generated column 824 + // 825 + // this requires a full table recreation because stored columns 826 + // cannot be added via alter 827 + // 828 + // couple other changes: 829 + // - columns renamed to be more consistent 830 + // - adds edited and deleted fields 831 + // 832 + // disable foreign-keys for the next migration 833 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 834 + runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 835 + _, err := tx.Exec(` 836 + create table if not exists issues_new ( 837 + -- identifiers 838 + id integer primary key autoincrement, 839 + did text not null, 840 + rkey text not null, 841 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored, 842 + 843 + -- at identifiers 844 + repo_at text not null, 845 + 846 + -- content 847 + issue_id integer not null, 848 + title text not null, 849 + body text not null, 850 + open integer not null default 1, 851 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 852 + edited text, -- timestamp 853 + deleted text, -- timestamp 854 + 855 + unique(did, rkey), 856 + unique(repo_at, issue_id), 857 + unique(at_uri), 858 + foreign key (repo_at) references repos(at_uri) on delete cascade 859 + ); 860 + `) 861 + if err != nil { 862 + return err 863 + } 864 + 865 + // transfer data 866 + _, err = tx.Exec(` 867 + insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created) 868 + select 869 + i.id, 870 + i.owner_did, 871 + i.rkey, 872 + i.repo_at, 873 + i.issue_id, 874 + i.title, 875 + i.body, 876 + i.open, 877 + i.created 878 + from issues i; 879 + `) 880 + if err != nil { 881 + return err 882 + } 883 + 884 + // drop old table 885 + _, err = tx.Exec(`drop table issues`) 886 + if err != nil { 887 + return err 888 + } 889 + 890 + // rename new table 891 + _, err = tx.Exec(`alter table issues_new rename to issues`) 892 + return err 893 + }) 894 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 895 + 896 + // - renames the comments table to 'issue_comments' 897 + // - rework issue comments to update constraints: 898 + // * unique(did, rkey) 899 + // * remove comment-id and just use the global ID 900 + // * foreign key (repo_at, issue_id) 901 + // - new columns 902 + // * column "reply_to" which can be any other comment 903 + // * column "at-uri" which is a generated column 904 + runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 905 + _, err := tx.Exec(` 906 + create table if not exists issue_comments ( 907 + -- identifiers 908 + id integer primary key autoincrement, 909 + did text not null, 910 + rkey text, 911 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored, 912 + 913 + -- at identifiers 914 + issue_at text not null, 915 + reply_to text, -- at_uri of parent comment 916 + 917 + -- content 918 + body text not null, 919 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 920 + edited text, 921 + deleted text, 922 + 923 + -- constraints 924 + unique(did, rkey), 925 + unique(at_uri), 926 + foreign key (issue_at) references issues(at_uri) on delete cascade 927 + ); 928 + `) 929 + if err != nil { 930 + return err 931 + } 932 + 933 + // transfer data 934 + _, err = tx.Exec(` 935 + insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted) 936 + select 937 + c.id, 938 + c.owner_did, 939 + c.rkey, 940 + i.at_uri, -- get at_uri from issues table 941 + c.body, 942 + c.created, 943 + c.edited, 944 + c.deleted 945 + from comments c 946 + join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id; 947 + `) 948 + if err != nil { 949 + return err 950 + } 951 + 952 + // drop old table 953 + _, err = tx.Exec(`drop table comments`) 954 + return err 955 + }) 956 + 957 + // add generated at_uri column to pulls table 958 + // 959 + // this requires a full table recreation because stored columns 960 + // cannot be added via alter 961 + // 962 + // disable foreign-keys for the next migration 963 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 + runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 + _, err := tx.Exec(` 966 + create table if not exists pulls_new ( 967 + -- identifiers 968 + id integer primary key autoincrement, 969 + pull_id integer not null, 970 + at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored, 971 + 972 + -- at identifiers 973 + repo_at text not null, 974 + owner_did text not null, 975 + rkey text not null, 976 + 977 + -- content 978 + title text not null, 979 + body text not null, 980 + target_branch text not null, 981 + state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 982 + 983 + -- source info 984 + source_branch text, 985 + source_repo_at text, 986 + 987 + -- stacking 988 + stack_id text, 989 + change_id text, 990 + parent_change_id text, 991 + 992 + -- meta 993 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 994 + 995 + -- constraints 996 + unique(repo_at, pull_id), 997 + unique(at_uri), 998 + foreign key (repo_at) references repos(at_uri) on delete cascade 999 + ); 1000 + `) 1001 + if err != nil { 1002 + return err 1003 + } 1004 + 1005 + // transfer data 1006 + _, err = tx.Exec(` 1007 + insert into pulls_new ( 1008 + id, pull_id, repo_at, owner_did, rkey, 1009 + title, body, target_branch, state, 1010 + source_branch, source_repo_at, 1011 + stack_id, change_id, parent_change_id, 1012 + created 1013 + ) 1014 + select 1015 + id, pull_id, repo_at, owner_did, rkey, 1016 + title, body, target_branch, state, 1017 + source_branch, source_repo_at, 1018 + stack_id, change_id, parent_change_id, 1019 + created 1020 + from pulls; 1021 + `) 1022 + if err != nil { 1023 + return err 1024 + } 1025 + 1026 + // drop old table 1027 + _, err = tx.Exec(`drop table pulls`) 1028 + if err != nil { 1029 + return err 1030 + } 1031 + 1032 + // rename new table 1033 + _, err = tx.Exec(`alter table pulls_new rename to pulls`) 1034 + return err 1035 + }) 1036 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1037 + 1038 + // remove repo_at and pull_id from pull_submissions and replace with pull_at 1039 + // 1040 + // this requires a full table recreation because stored columns 1041 + // cannot be added via alter 1042 + // 1043 + // disable foreign-keys for the next migration 1044 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 + runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 + _, err := tx.Exec(` 1047 + create table if not exists pull_submissions_new ( 1048 + -- identifiers 1049 + id integer primary key autoincrement, 1050 + pull_at text not null, 1051 + 1052 + -- content, these are immutable, and require a resubmission to update 1053 + round_number integer not null default 0, 1054 + patch text, 1055 + source_rev text, 1056 + 1057 + -- meta 1058 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1059 + 1060 + -- constraints 1061 + unique(pull_at, round_number), 1062 + foreign key (pull_at) references pulls(at_uri) on delete cascade 1063 + ); 1064 + `) 1065 + if err != nil { 1066 + return err 1067 + } 1068 + 1069 + // transfer data, constructing pull_at from pulls table 1070 + _, err = tx.Exec(` 1071 + insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1072 + select 1073 + ps.id, 1074 + 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1075 + ps.round_number, 1076 + ps.patch, 1077 + ps.created 1078 + from pull_submissions ps 1079 + join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id; 1080 + `) 1081 + if err != nil { 1082 + return err 1083 + } 1084 + 1085 + // drop old table 1086 + _, err = tx.Exec(`drop table pull_submissions`) 1087 + if err != nil { 1088 + return err 1089 + } 1090 + 1091 + // rename new table 1092 + _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`) 1093 + return err 1094 + }) 1095 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 + 706 1097 return &DB{db}, nil 707 1098 } 708 1099 ··· 749 1140 return nil 750 1141 } 751 1142 1143 + func (d *DB) Close() error { 1144 + return d.DB.Close() 1145 + } 1146 + 752 1147 type filter struct { 753 1148 key string 754 1149 arg any ··· 763 1158 } 764 1159 } 765 1160 766 - func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 767 - func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 768 - func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 769 - func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 770 - func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 771 - func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 772 - func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1161 + func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 1162 + func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 1163 + func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 1164 + func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 1165 + func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 1166 + func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 1167 + func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1168 + func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) } 1169 + func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) } 1170 + func FilterContains(key string, arg any) filter { 1171 + return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 1172 + } 773 1173 774 1174 func (f filter) Condition() string { 775 1175 rv := reflect.ValueOf(f.arg)
+29 -34
appview/db/email.go
··· 3 3 import ( 4 4 "strings" 5 5 "time" 6 - ) 7 6 8 - type Email struct { 9 - ID int64 10 - Did string 11 - Address string 12 - Verified bool 13 - Primary bool 14 - VerificationCode string 15 - LastSent *time.Time 16 - CreatedAt time.Time 17 - } 7 + "tangled.org/core/appview/models" 8 + ) 18 9 19 - func GetPrimaryEmail(e Execer, did string) (Email, error) { 10 + func GetPrimaryEmail(e Execer, did string) (models.Email, error) { 20 11 query := ` 21 12 select id, did, email, verified, is_primary, verification_code, last_sent, created 22 13 from emails 23 14 where did = ? and is_primary = true 24 15 ` 25 - var email Email 16 + var email models.Email 26 17 var createdStr string 27 18 var lastSent string 28 19 err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 29 20 if err != nil { 30 - return Email{}, err 21 + return models.Email{}, err 31 22 } 32 23 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 33 24 if err != nil { 34 - return Email{}, err 25 + return models.Email{}, err 35 26 } 36 27 parsedTime, err := time.Parse(time.RFC3339, lastSent) 37 28 if err != nil { 38 - return Email{}, err 29 + return models.Email{}, err 39 30 } 40 31 email.LastSent = &parsedTime 41 32 return email, nil 42 33 } 43 34 44 - func GetEmail(e Execer, did string, em string) (Email, error) { 35 + func GetEmail(e Execer, did string, em string) (models.Email, error) { 45 36 query := ` 46 37 select id, did, email, verified, is_primary, verification_code, last_sent, created 47 38 from emails 48 39 where did = ? and email = ? 49 40 ` 50 - var email Email 41 + var email models.Email 51 42 var createdStr string 52 43 var lastSent string 53 44 err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 54 45 if err != nil { 55 - return Email{}, err 46 + return models.Email{}, err 56 47 } 57 48 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 58 49 if err != nil { 59 - return Email{}, err 50 + return models.Email{}, err 60 51 } 61 52 parsedTime, err := time.Parse(time.RFC3339, lastSent) 62 53 if err != nil { 63 - return Email{}, err 54 + return models.Email{}, err 64 55 } 65 56 email.LastSent = &parsedTime 66 57 return email, nil ··· 80 71 return did, nil 81 72 } 82 73 83 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 84 - if len(ems) == 0 { 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 85 76 return make(map[string]string), nil 86 77 } 87 78 ··· 90 81 verifiedFilter = 1 91 82 } 92 83 84 + assoc := make(map[string]string) 85 + 93 86 // Create placeholders for the IN clause 94 - placeholders := make([]string, len(ems)) 95 - args := make([]any, len(ems)+1) 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 96 89 97 90 args[0] = verifiedFilter 98 - for i, em := range ems { 99 - placeholders[i] = "?" 100 - args[i+1] = em 91 + for _, email := range emails { 92 + if strings.HasPrefix(email, "did:") { 93 + assoc[email] = email 94 + continue 95 + } 96 + placeholders = append(placeholders, "?") 97 + args = append(args, email) 101 98 } 102 99 103 100 query := ` ··· 113 110 return nil, err 114 111 } 115 112 defer rows.Close() 116 - 117 - assoc := make(map[string]string) 118 113 119 114 for rows.Next() { 120 115 var email, did string ··· 187 182 return count > 0, nil 188 183 } 189 184 190 - func AddEmail(e Execer, email Email) error { 185 + func AddEmail(e Execer, email models.Email) error { 191 186 // Check if this is the first email for this DID 192 187 countQuery := ` 193 188 select count(*) ··· 254 249 return err 255 250 } 256 251 257 - func GetAllEmails(e Execer, did string) ([]Email, error) { 252 + func GetAllEmails(e Execer, did string) ([]models.Email, error) { 258 253 query := ` 259 254 select did, email, verified, is_primary, verification_code, last_sent, created 260 255 from emails ··· 266 261 } 267 262 defer rows.Close() 268 263 269 - var emails []Email 264 + var emails []models.Email 270 265 for rows.Next() { 271 - var email Email 266 + var email models.Email 272 267 var createdStr string 273 268 var lastSent string 274 269 err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
+81 -52
appview/db/follow.go
··· 5 5 "log" 6 6 "strings" 7 7 "time" 8 + 9 + "tangled.org/core/appview/models" 8 10 ) 9 11 10 - type Follow struct { 11 - UserDid string 12 - SubjectDid string 13 - FollowedAt time.Time 14 - Rkey string 15 - } 16 - 17 - func AddFollow(e Execer, follow *Follow) error { 12 + func AddFollow(e Execer, follow *models.Follow) error { 18 13 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 19 14 _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 20 15 return err 21 16 } 22 17 23 18 // Get a follow record 24 - func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) { 19 + func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { 25 20 query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` 26 21 row := e.QueryRow(query, userDid, subjectDid) 27 22 28 - var follow Follow 23 + var follow models.Follow 29 24 var followedAt string 30 25 err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 31 26 if err != nil { ··· 55 50 return err 56 51 } 57 52 58 - type FollowStats struct { 59 - Followers int 60 - Following int 61 - } 62 - 63 - func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 - followers, following := 0, 0 53 + func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) { 54 + var followers, following int64 65 55 err := e.QueryRow( 66 56 `SELECT 67 57 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 68 58 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 69 59 FROM follows;`, did, did).Scan(&followers, &following) 70 60 if err != nil { 71 - return FollowStats{}, err 61 + return models.FollowStats{}, err 72 62 } 73 - return FollowStats{ 63 + return models.FollowStats{ 74 64 Followers: followers, 75 65 Following: following, 76 66 }, nil 77 67 } 78 68 79 - func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 69 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) { 80 70 if len(dids) == 0 { 81 71 return nil, nil 82 72 } ··· 112 102 ) g on f.did = g.did`, 113 103 placeholderStr, placeholderStr) 114 104 115 - result := make(map[string]FollowStats) 105 + result := make(map[string]models.FollowStats) 116 106 117 107 rows, err := e.Query(query, args...) 118 108 if err != nil { ··· 122 112 123 113 for rows.Next() { 124 114 var did string 125 - var followers, following int 115 + var followers, following int64 126 116 if err := rows.Scan(&did, &followers, &following); err != nil { 127 117 return nil, err 128 118 } 129 - result[did] = FollowStats{ 119 + result[did] = models.FollowStats{ 130 120 Followers: followers, 131 121 Following: following, 132 122 } ··· 134 124 135 125 for _, did := range dids { 136 126 if _, exists := result[did]; !exists { 137 - result[did] = FollowStats{ 127 + result[did] = models.FollowStats{ 138 128 Followers: 0, 139 129 Following: 0, 140 130 } ··· 144 134 return result, nil 145 135 } 146 136 147 - func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 148 - var follows []Follow 137 + func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) { 138 + var follows []models.Follow 149 139 150 140 var conditions []string 151 141 var args []any ··· 177 167 return nil, err 178 168 } 179 169 for rows.Next() { 180 - var follow Follow 170 + var follow models.Follow 181 171 var followedAt string 182 172 err := rows.Scan( 183 173 &follow.UserDid, ··· 200 190 return follows, nil 201 191 } 202 192 203 - func GetFollowers(e Execer, did string) ([]Follow, error) { 193 + func GetFollowers(e Execer, did string) ([]models.Follow, error) { 204 194 return GetFollows(e, 0, FilterEq("subject_did", did)) 205 195 } 206 196 207 - func GetFollowing(e Execer, did string) ([]Follow, error) { 197 + func GetFollowing(e Execer, did string) ([]models.Follow, error) { 208 198 return GetFollows(e, 0, FilterEq("user_did", did)) 209 199 } 210 200 211 - type FollowStatus int 201 + func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { 202 + if len(subjectDids) == 0 || userDid == "" { 203 + return make(map[string]models.FollowStatus), nil 204 + } 205 + 206 + result := make(map[string]models.FollowStatus) 207 + 208 + for _, subjectDid := range subjectDids { 209 + if userDid == subjectDid { 210 + result[subjectDid] = models.IsSelf 211 + } else { 212 + result[subjectDid] = models.IsNotFollowing 213 + } 214 + } 215 + 216 + var querySubjects []string 217 + for _, subjectDid := range subjectDids { 218 + if userDid != subjectDid { 219 + querySubjects = append(querySubjects, subjectDid) 220 + } 221 + } 222 + 223 + if len(querySubjects) == 0 { 224 + return result, nil 225 + } 212 226 213 - const ( 214 - IsNotFollowing FollowStatus = iota 215 - IsFollowing 216 - IsSelf 217 - ) 227 + placeholders := make([]string, len(querySubjects)) 228 + args := make([]any, len(querySubjects)+1) 229 + args[0] = userDid 218 230 219 - func (s FollowStatus) String() string { 220 - switch s { 221 - case IsNotFollowing: 222 - return "IsNotFollowing" 223 - case IsFollowing: 224 - return "IsFollowing" 225 - case IsSelf: 226 - return "IsSelf" 227 - default: 228 - return "IsNotFollowing" 231 + for i, subjectDid := range querySubjects { 232 + placeholders[i] = "?" 233 + args[i+1] = subjectDid 229 234 } 235 + 236 + query := fmt.Sprintf(` 237 + SELECT subject_did 238 + FROM follows 239 + WHERE user_did = ? AND subject_did IN (%s) 240 + `, strings.Join(placeholders, ",")) 241 + 242 + rows, err := e.Query(query, args...) 243 + if err != nil { 244 + return nil, err 245 + } 246 + defer rows.Close() 247 + 248 + for rows.Next() { 249 + var subjectDid string 250 + if err := rows.Scan(&subjectDid); err != nil { 251 + return nil, err 252 + } 253 + result[subjectDid] = models.IsFollowing 254 + } 255 + 256 + return result, nil 230 257 } 231 258 232 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 233 - if userDid == subjectDid { 234 - return IsSelf 235 - } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 236 - return IsNotFollowing 237 - } else { 238 - return IsFollowing 259 + func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus { 260 + statuses, err := getFollowStatuses(e, userDid, []string{subjectDid}) 261 + if err != nil { 262 + return models.IsNotFollowing 239 263 } 264 + return statuses[subjectDid] 265 + } 266 + 267 + func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { 268 + return getFollowStatuses(e, userDid, subjectDids) 240 269 }
+311 -511
appview/db/issues.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 - mathrand "math/rand/v2" 6 + "maps" 7 + "slices" 8 + "sort" 7 9 "strings" 8 10 "time" 9 11 10 12 "github.com/bluesky-social/indigo/atproto/syntax" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview/pagination" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pagination" 13 15 ) 14 16 15 - type Issue struct { 16 - ID int64 17 - RepoAt syntax.ATURI 18 - OwnerDid string 19 - IssueId int 20 - Rkey string 21 - Created time.Time 22 - Title string 23 - Body string 24 - Open bool 25 - 26 - // optionally, populate this when querying for reverse mappings 27 - // like comment counts, parent repo etc. 28 - Metadata *IssueMetadata 29 - } 30 - 31 - type IssueMetadata struct { 32 - CommentCount int 33 - Repo *Repo 34 - // labels, assignee etc. 35 - } 36 - 37 - type Comment struct { 38 - OwnerDid string 39 - RepoAt syntax.ATURI 40 - Rkey string 41 - Issue int 42 - CommentId int 43 - Body string 44 - Created *time.Time 45 - Deleted *time.Time 46 - Edited *time.Time 47 - } 48 - 49 - func (i *Issue) AtUri() syntax.ATURI { 50 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 51 - } 52 - 53 - func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 54 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 55 - if err != nil { 56 - created = time.Now() 57 - } 58 - 59 - body := "" 60 - if record.Body != nil { 61 - body = *record.Body 62 - } 63 - 64 - return Issue{ 65 - RepoAt: syntax.ATURI(record.Repo), 66 - OwnerDid: did, 67 - Rkey: rkey, 68 - Created: created, 69 - Title: record.Title, 70 - Body: body, 71 - Open: true, // new issues are open by default 72 - } 73 - } 74 - 75 - func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) { 76 - ownerDid := issueUri.Authority().String() 77 - issueRkey := issueUri.RecordKey().String() 78 - 79 - var repoAt string 80 - var issueId int 81 - 82 - query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?` 83 - err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId) 84 - if err != nil { 85 - return "", 0, err 86 - } 87 - 88 - return syntax.ATURI(repoAt), issueId, nil 89 - } 90 - 91 - func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) { 92 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 - if err != nil { 94 - created = time.Now() 95 - } 96 - 97 - ownerDid := did 98 - if record.Owner != nil { 99 - ownerDid = *record.Owner 100 - } 101 - 102 - issueUri, err := syntax.ParseATURI(record.Issue) 103 - if err != nil { 104 - return Comment{}, err 105 - } 106 - 107 - repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri) 108 - if err != nil { 109 - return Comment{}, err 110 - } 111 - 112 - comment := Comment{ 113 - OwnerDid: ownerDid, 114 - RepoAt: repoAt, 115 - Rkey: rkey, 116 - Body: record.Body, 117 - Issue: issueId, 118 - CommentId: mathrand.IntN(1000000), 119 - Created: &created, 120 - } 121 - 122 - return comment, nil 123 - } 124 - 125 - func NewIssue(tx *sql.Tx, issue *Issue) error { 126 - defer tx.Rollback() 127 - 17 + func PutIssue(tx *sql.Tx, issue *models.Issue) error { 18 + // ensure sequence exists 128 19 _, err := tx.Exec(` 129 20 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 130 21 values (?, 1) 131 - `, issue.RepoAt) 22 + `, issue.RepoAt) 132 23 if err != nil { 133 24 return err 134 25 } 135 26 136 - var nextId int 137 - err = tx.QueryRow(` 27 + issues, err := GetIssues( 28 + tx, 29 + FilterEq("did", issue.Did), 30 + FilterEq("rkey", issue.Rkey), 31 + ) 32 + switch { 33 + case err != nil: 34 + return err 35 + case len(issues) == 0: 36 + return createNewIssue(tx, issue) 37 + case len(issues) != 1: // should be unreachable 38 + return fmt.Errorf("invalid number of issues returned: %d", len(issues)) 39 + default: 40 + // if content is identical, do not edit 41 + existingIssue := issues[0] 42 + if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body { 43 + return nil 44 + } 45 + 46 + issue.Id = existingIssue.Id 47 + issue.IssueId = existingIssue.IssueId 48 + return updateIssue(tx, issue) 49 + } 50 + } 51 + 52 + func createNewIssue(tx *sql.Tx, issue *models.Issue) error { 53 + // get next issue_id 54 + var newIssueId int 55 + err := tx.QueryRow(` 138 56 update repo_issue_seqs 139 57 set next_issue_id = next_issue_id + 1 140 58 where repo_at = ? 141 59 returning next_issue_id - 1 142 - `, issue.RepoAt).Scan(&nextId) 143 - if err != nil { 144 - return err 145 - } 146 - 147 - issue.IssueId = nextId 148 - 149 - res, err := tx.Exec(` 150 - insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body) 151 - values (?, ?, ?, ?, ?, ?, ?) 152 - `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body) 60 + `, issue.RepoAt).Scan(&newIssueId) 153 61 if err != nil { 154 62 return err 155 63 } 156 64 157 - lastID, err := res.LastInsertId() 158 - if err != nil { 159 - return err 160 - } 161 - issue.ID = lastID 65 + // insert new issue 66 + row := tx.QueryRow(` 67 + insert into issues (repo_at, did, rkey, issue_id, title, body) 68 + values (?, ?, ?, ?, ?, ?) 69 + returning rowid, issue_id 70 + `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 162 71 163 - if err := tx.Commit(); err != nil { 164 - return err 165 - } 166 - 167 - return nil 72 + return row.Scan(&issue.Id, &issue.IssueId) 168 73 } 169 74 170 - func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 171 - var issueAt string 172 - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 173 - return issueAt, err 75 + func updateIssue(tx *sql.Tx, issue *models.Issue) error { 76 + // update existing issue 77 + _, err := tx.Exec(` 78 + update issues 79 + set title = ?, body = ?, edited = ? 80 + where did = ? and rkey = ? 81 + `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 82 + return err 174 83 } 175 84 176 - func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 177 - var ownerDid string 178 - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 179 - return ownerDid, err 180 - } 181 - 182 - func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 183 - var issues []Issue 184 - openValue := 0 185 - if isOpen { 186 - openValue = 1 187 - } 188 - 189 - rows, err := e.Query( 190 - ` 191 - with numbered_issue as ( 192 - select 193 - i.id, 194 - i.owner_did, 195 - i.rkey, 196 - i.issue_id, 197 - i.created, 198 - i.title, 199 - i.body, 200 - i.open, 201 - count(c.id) as comment_count, 202 - row_number() over (order by i.created desc) as row_num 203 - from 204 - issues i 205 - left join 206 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 207 - where 208 - i.repo_at = ? and i.open = ? 209 - group by 210 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 211 - ) 212 - select 213 - id, 214 - owner_did, 215 - rkey, 216 - issue_id, 217 - created, 218 - title, 219 - body, 220 - open, 221 - comment_count 222 - from 223 - numbered_issue 224 - where 225 - row_num between ? and ?`, 226 - repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 227 - if err != nil { 228 - return nil, err 229 - } 230 - defer rows.Close() 231 - 232 - for rows.Next() { 233 - var issue Issue 234 - var createdAt string 235 - var metadata IssueMetadata 236 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 237 - if err != nil { 238 - return nil, err 239 - } 240 - 241 - createdTime, err := time.Parse(time.RFC3339, createdAt) 242 - if err != nil { 243 - return nil, err 244 - } 245 - issue.Created = createdTime 246 - issue.Metadata = &metadata 247 - 248 - issues = append(issues, issue) 249 - } 250 - 251 - if err := rows.Err(); err != nil { 252 - return nil, err 253 - } 254 - 255 - return issues, nil 256 - } 257 - 258 - func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 259 - issues := make([]Issue, 0, limit) 85 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 86 + issueMap := make(map[string]*models.Issue) // at-uri -> issue 260 87 261 88 var conditions []string 262 89 var args []any 90 + 263 91 for _, filter := range filters { 264 92 conditions = append(conditions, filter.Condition()) 265 93 args = append(args, filter.Arg()...) ··· 269 97 if conditions != nil { 270 98 whereClause = " where " + strings.Join(conditions, " and ") 271 99 } 272 - limitClause := "" 273 - if limit != 0 { 274 - limitClause = fmt.Sprintf(" limit %d ", limit) 275 - } 100 + 101 + pLower := FilterGte("row_num", page.Offset+1) 102 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 103 + 104 + args = append(args, pLower.Arg()...) 105 + args = append(args, pUpper.Arg()...) 106 + pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 276 107 277 108 query := fmt.Sprintf( 278 - `select 279 - i.id, 280 - i.owner_did, 281 - i.repo_at, 282 - i.issue_id, 283 - i.created, 284 - i.title, 285 - i.body, 286 - i.open 287 - from 288 - issues i 109 + ` 110 + select * from ( 111 + select 112 + id, 113 + did, 114 + rkey, 115 + repo_at, 116 + issue_id, 117 + title, 118 + body, 119 + open, 120 + created, 121 + edited, 122 + deleted, 123 + row_number() over (order by created desc) as row_num 124 + from 125 + issues 126 + %s 127 + ) ranked_issues 289 128 %s 290 - order by 291 - i.created desc 292 - %s`, 293 - whereClause, limitClause) 129 + `, 130 + whereClause, 131 + pagination, 132 + ) 294 133 295 134 rows, err := e.Query(query, args...) 296 135 if err != nil { 297 - return nil, err 136 + return nil, fmt.Errorf("failed to query issues table: %w", err) 298 137 } 299 138 defer rows.Close() 300 139 301 140 for rows.Next() { 302 - var issue Issue 303 - var issueCreatedAt string 141 + var issue models.Issue 142 + var createdAt string 143 + var editedAt, deletedAt sql.Null[string] 144 + var rowNum int64 304 145 err := rows.Scan( 305 - &issue.ID, 306 - &issue.OwnerDid, 146 + &issue.Id, 147 + &issue.Did, 148 + &issue.Rkey, 307 149 &issue.RepoAt, 308 150 &issue.IssueId, 309 - &issueCreatedAt, 310 151 &issue.Title, 311 152 &issue.Body, 312 153 &issue.Open, 154 + &createdAt, 155 + &editedAt, 156 + &deletedAt, 157 + &rowNum, 313 158 ) 314 159 if err != nil { 315 - return nil, err 160 + return nil, fmt.Errorf("failed to scan issue: %w", err) 316 161 } 317 162 318 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 319 - if err != nil { 320 - return nil, err 163 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 164 + issue.Created = t 321 165 } 322 - issue.Created = issueCreatedTime 323 166 324 - issues = append(issues, issue) 325 - } 167 + if editedAt.Valid { 168 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 169 + issue.Edited = &t 170 + } 171 + } 326 172 327 - if err := rows.Err(); err != nil { 328 - return nil, err 173 + if deletedAt.Valid { 174 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 175 + issue.Deleted = &t 176 + } 177 + } 178 + 179 + atUri := issue.AtUri().String() 180 + issueMap[atUri] = &issue 329 181 } 330 182 331 - return issues, nil 332 - } 183 + // collect reverse repos 184 + repoAts := make([]string, 0, len(issueMap)) // or just []string{} 185 + for _, issue := range issueMap { 186 + repoAts = append(repoAts, string(issue.RepoAt)) 187 + } 333 188 334 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 335 - return GetIssuesWithLimit(e, 0, filters...) 336 - } 337 - 338 - // timeframe here is directly passed into the sql query filter, and any 339 - // timeframe in the past should be negative; e.g.: "-3 months" 340 - func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { 341 - var issues []Issue 342 - 343 - rows, err := e.Query( 344 - `select 345 - i.id, 346 - i.owner_did, 347 - i.rkey, 348 - i.repo_at, 349 - i.issue_id, 350 - i.created, 351 - i.title, 352 - i.body, 353 - i.open, 354 - r.did, 355 - r.name, 356 - r.knot, 357 - r.rkey, 358 - r.created 359 - from 360 - issues i 361 - join 362 - repos r on i.repo_at = r.at_uri 363 - where 364 - i.owner_did = ? and i.created >= date ('now', ?) 365 - order by 366 - i.created desc`, 367 - ownerDid, timeframe) 189 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 368 190 if err != nil { 369 - return nil, err 191 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 370 192 } 371 - defer rows.Close() 372 193 373 - for rows.Next() { 374 - var issue Issue 375 - var issueCreatedAt, repoCreatedAt string 376 - var repo Repo 377 - err := rows.Scan( 378 - &issue.ID, 379 - &issue.OwnerDid, 380 - &issue.Rkey, 381 - &issue.RepoAt, 382 - &issue.IssueId, 383 - &issueCreatedAt, 384 - &issue.Title, 385 - &issue.Body, 386 - &issue.Open, 387 - &repo.Did, 388 - &repo.Name, 389 - &repo.Knot, 390 - &repo.Rkey, 391 - &repoCreatedAt, 392 - ) 393 - if err != nil { 394 - return nil, err 395 - } 194 + repoMap := make(map[string]*models.Repo) 195 + for i := range repos { 196 + repoMap[string(repos[i].RepoAt())] = &repos[i] 197 + } 396 198 397 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 398 - if err != nil { 399 - return nil, err 199 + for issueAt, i := range issueMap { 200 + if r, ok := repoMap[string(i.RepoAt)]; ok { 201 + i.Repo = r 202 + } else { 203 + // do not show up the issue if the repo is deleted 204 + // TODO: foreign key where? 205 + delete(issueMap, issueAt) 400 206 } 401 - issue.Created = issueCreatedTime 207 + } 208 + 209 + // collect comments 210 + issueAts := slices.Collect(maps.Keys(issueMap)) 402 211 403 - repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 404 - if err != nil { 405 - return nil, err 212 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 213 + if err != nil { 214 + return nil, fmt.Errorf("failed to query comments: %w", err) 215 + } 216 + for i := range comments { 217 + issueAt := comments[i].IssueAt 218 + if issue, ok := issueMap[issueAt]; ok { 219 + issue.Comments = append(issue.Comments, comments[i]) 406 220 } 407 - repo.Created = repoCreatedTime 221 + } 408 222 409 - issue.Metadata = &IssueMetadata{ 410 - Repo: &repo, 223 + // collect allLabels for each issue 224 + allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 225 + if err != nil { 226 + return nil, fmt.Errorf("failed to query labels: %w", err) 227 + } 228 + for issueAt, labels := range allLabels { 229 + if issue, ok := issueMap[issueAt.String()]; ok { 230 + issue.Labels = labels 411 231 } 232 + } 412 233 413 - issues = append(issues, issue) 234 + var issues []models.Issue 235 + for _, i := range issueMap { 236 + issues = append(issues, *i) 414 237 } 415 238 416 - if err := rows.Err(); err != nil { 417 - return nil, err 418 - } 239 + sort.Slice(issues, func(i, j int) bool { 240 + return issues[i].Created.After(issues[j].Created) 241 + }) 419 242 420 243 return issues, nil 421 244 } 422 245 423 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 246 + func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 247 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 248 + } 249 + 250 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 424 251 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 425 252 row := e.QueryRow(query, repoAt, issueId) 426 253 427 - var issue Issue 254 + var issue models.Issue 428 255 var createdAt string 429 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 256 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 430 257 if err != nil { 431 258 return nil, err 432 259 } ··· 440 267 return &issue, nil 441 268 } 442 269 443 - func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 444 - query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 445 - row := e.QueryRow(query, repoAt, issueId) 446 - 447 - var issue Issue 448 - var createdAt string 449 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 270 + func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 271 + result, err := e.Exec( 272 + `insert into issue_comments ( 273 + did, 274 + rkey, 275 + issue_at, 276 + body, 277 + reply_to, 278 + created, 279 + edited 280 + ) 281 + values (?, ?, ?, ?, ?, ?, null) 282 + on conflict(did, rkey) do update set 283 + issue_at = excluded.issue_at, 284 + body = excluded.body, 285 + edited = case 286 + when 287 + issue_comments.issue_at != excluded.issue_at 288 + or issue_comments.body != excluded.body 289 + or issue_comments.reply_to != excluded.reply_to 290 + then ? 291 + else issue_comments.edited 292 + end`, 293 + c.Did, 294 + c.Rkey, 295 + c.IssueAt, 296 + c.Body, 297 + c.ReplyTo, 298 + c.Created.Format(time.RFC3339), 299 + time.Now().Format(time.RFC3339), 300 + ) 450 301 if err != nil { 451 - return nil, nil, err 302 + return 0, err 452 303 } 453 304 454 - createdTime, err := time.Parse(time.RFC3339, createdAt) 305 + id, err := result.LastInsertId() 455 306 if err != nil { 456 - return nil, nil, err 307 + return 0, err 457 308 } 458 - issue.Created = createdTime 459 309 460 - comments, err := GetComments(e, repoAt, issueId) 461 - if err != nil { 462 - return nil, nil, err 310 + return id, nil 311 + } 312 + 313 + func DeleteIssueComments(e Execer, filters ...filter) error { 314 + var conditions []string 315 + var args []any 316 + for _, filter := range filters { 317 + conditions = append(conditions, filter.Condition()) 318 + args = append(args, filter.Arg()...) 319 + } 320 + 321 + whereClause := "" 322 + if conditions != nil { 323 + whereClause = " where " + strings.Join(conditions, " and ") 463 324 } 464 325 465 - return &issue, comments, nil 466 - } 326 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 467 327 468 - func NewIssueComment(e Execer, comment *Comment) error { 469 - query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 470 - _, err := e.Exec( 471 - query, 472 - comment.OwnerDid, 473 - comment.RepoAt, 474 - comment.Rkey, 475 - comment.Issue, 476 - comment.CommentId, 477 - comment.Body, 478 - ) 328 + _, err := e.Exec(query, args...) 479 329 return err 480 330 } 481 331 482 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 483 - var comments []Comment 332 + func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 333 + var comments []models.IssueComment 334 + 335 + var conditions []string 336 + var args []any 337 + for _, filter := range filters { 338 + conditions = append(conditions, filter.Condition()) 339 + args = append(args, filter.Arg()...) 340 + } 341 + 342 + whereClause := "" 343 + if conditions != nil { 344 + whereClause = " where " + strings.Join(conditions, " and ") 345 + } 484 346 485 - rows, err := e.Query(` 347 + query := fmt.Sprintf(` 486 348 select 487 - owner_did, 488 - issue_id, 489 - comment_id, 349 + id, 350 + did, 490 351 rkey, 352 + issue_at, 353 + reply_to, 491 354 body, 492 355 created, 493 356 edited, 494 357 deleted 495 358 from 496 - comments 497 - where 498 - repo_at = ? and issue_id = ? 499 - order by 500 - created asc`, 501 - repoAt, 502 - issueId, 503 - ) 504 - if err == sql.ErrNoRows { 505 - return []Comment{}, nil 506 - } 359 + issue_comments 360 + %s 361 + `, whereClause) 362 + 363 + rows, err := e.Query(query, args...) 507 364 if err != nil { 508 365 return nil, err 509 366 } 510 - defer rows.Close() 511 367 512 368 for rows.Next() { 513 - var comment Comment 514 - var createdAt string 515 - var deletedAt, editedAt, rkey sql.NullString 516 - err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt) 369 + var comment models.IssueComment 370 + var created string 371 + var rkey, edited, deleted, replyTo sql.Null[string] 372 + err := rows.Scan( 373 + &comment.Id, 374 + &comment.Did, 375 + &rkey, 376 + &comment.IssueAt, 377 + &replyTo, 378 + &comment.Body, 379 + &created, 380 + &edited, 381 + &deleted, 382 + ) 517 383 if err != nil { 518 384 return nil, err 519 385 } 520 386 521 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 522 - if err != nil { 523 - return nil, err 387 + // this is a remnant from old times, newer comments always have rkey 388 + if rkey.Valid { 389 + comment.Rkey = rkey.V 524 390 } 525 - comment.Created = &createdAtTime 526 391 527 - if deletedAt.Valid { 528 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 529 - if err != nil { 530 - return nil, err 392 + if t, err := time.Parse(time.RFC3339, created); err == nil { 393 + comment.Created = t 394 + } 395 + 396 + if edited.Valid { 397 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 398 + comment.Edited = &t 531 399 } 532 - comment.Deleted = &deletedTime 533 400 } 534 401 535 - if editedAt.Valid { 536 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 537 - if err != nil { 538 - return nil, err 402 + if deleted.Valid { 403 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 404 + comment.Deleted = &t 539 405 } 540 - comment.Edited = &editedTime 541 406 } 542 407 543 - if rkey.Valid { 544 - comment.Rkey = rkey.String 408 + if replyTo.Valid { 409 + comment.ReplyTo = &replyTo.V 545 410 } 546 411 547 412 comments = append(comments, comment) 548 413 } 549 414 550 - if err := rows.Err(); err != nil { 415 + if err = rows.Err(); err != nil { 551 416 return nil, err 552 417 } 553 418 554 419 return comments, nil 555 420 } 556 421 557 - func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { 558 - query := ` 559 - select 560 - owner_did, body, rkey, created, deleted, edited 561 - from 562 - comments where repo_at = ? and issue_id = ? and comment_id = ? 563 - ` 564 - row := e.QueryRow(query, repoAt, issueId, commentId) 565 - 566 - var comment Comment 567 - var createdAt string 568 - var deletedAt, editedAt, rkey sql.NullString 569 - err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt) 570 - if err != nil { 571 - return nil, err 422 + func DeleteIssues(e Execer, filters ...filter) error { 423 + var conditions []string 424 + var args []any 425 + for _, filter := range filters { 426 + conditions = append(conditions, filter.Condition()) 427 + args = append(args, filter.Arg()...) 572 428 } 573 429 574 - createdTime, err := time.Parse(time.RFC3339, createdAt) 575 - if err != nil { 576 - return nil, err 430 + whereClause := "" 431 + if conditions != nil { 432 + whereClause = " where " + strings.Join(conditions, " and ") 577 433 } 578 - comment.Created = &createdTime 579 434 580 - if deletedAt.Valid { 581 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 582 - if err != nil { 583 - return nil, err 584 - } 585 - comment.Deleted = &deletedTime 586 - } 435 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 436 + _, err := e.Exec(query, args...) 437 + return err 438 + } 587 439 588 - if editedAt.Valid { 589 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 590 - if err != nil { 591 - return nil, err 592 - } 593 - comment.Edited = &editedTime 440 + func CloseIssues(e Execer, filters ...filter) error { 441 + var conditions []string 442 + var args []any 443 + for _, filter := range filters { 444 + conditions = append(conditions, filter.Condition()) 445 + args = append(args, filter.Arg()...) 594 446 } 595 447 596 - if rkey.Valid { 597 - comment.Rkey = rkey.String 448 + whereClause := "" 449 + if conditions != nil { 450 + whereClause = " where " + strings.Join(conditions, " and ") 598 451 } 599 452 600 - comment.RepoAt = repoAt 601 - comment.Issue = issueId 602 - comment.CommentId = commentId 603 - 604 - return &comment, nil 605 - } 606 - 607 - func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { 608 - _, err := e.Exec( 609 - ` 610 - update comments 611 - set body = ?, 612 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 613 - where repo_at = ? and issue_id = ? and comment_id = ? 614 - `, newBody, repoAt, issueId, commentId) 615 - return err 616 - } 617 - 618 - func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 619 - _, err := e.Exec( 620 - ` 621 - update comments 622 - set body = "", 623 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 624 - where repo_at = ? and issue_id = ? and comment_id = ? 625 - `, repoAt, issueId, commentId) 626 - return err 627 - } 628 - 629 - func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error { 630 - _, err := e.Exec( 631 - ` 632 - update comments 633 - set body = ?, 634 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 635 - where owner_did = ? and rkey = ? 636 - `, newBody, ownerDid, rkey) 637 - return err 638 - } 639 - 640 - func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 641 - _, err := e.Exec( 642 - ` 643 - update comments 644 - set body = "", 645 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 646 - where owner_did = ? and rkey = ? 647 - `, ownerDid, rkey) 453 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 454 + _, err := e.Exec(query, args...) 648 455 return err 649 456 } 650 457 651 - func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 652 - _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 653 - return err 654 - } 458 + func ReopenIssues(e Execer, filters ...filter) error { 459 + var conditions []string 460 + var args []any 461 + for _, filter := range filters { 462 + conditions = append(conditions, filter.Condition()) 463 + args = append(args, filter.Arg()...) 464 + } 655 465 656 - func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 657 - _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 658 - return err 659 - } 466 + whereClause := "" 467 + if conditions != nil { 468 + whereClause = " where " + strings.Join(conditions, " and ") 469 + } 660 470 661 - func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 662 - _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 471 + query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 472 + _, err := e.Exec(query, args...) 663 473 return err 664 474 } 665 475 666 - func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 667 - _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId) 668 - return err 669 - } 670 - 671 - type IssueCount struct { 672 - Open int 673 - Closed int 674 - } 675 - 676 - func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) { 476 + func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) { 677 477 row := e.QueryRow(` 678 478 select 679 479 count(case when open = 1 then 1 end) as open_count, ··· 683 483 repoAt, 684 484 ) 685 485 686 - var count IssueCount 486 + var count models.IssueCount 687 487 if err := row.Scan(&count.Open, &count.Closed); err != nil { 688 - return IssueCount{0, 0}, err 488 + return models.IssueCount{}, err 689 489 } 690 490 691 491 return count, nil
+353
appview/db/label.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "maps" 7 + "slices" 8 + "strings" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "tangled.org/core/appview/models" 13 + ) 14 + 15 + // no updating type for now 16 + func AddLabelDefinition(e Execer, l *models.LabelDefinition) (int64, error) { 17 + result, err := e.Exec( 18 + `insert into label_definitions ( 19 + did, 20 + rkey, 21 + name, 22 + value_type, 23 + value_format, 24 + value_enum, 25 + scope, 26 + color, 27 + multiple, 28 + created 29 + ) 30 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 31 + on conflict(did, rkey) do update set 32 + name = excluded.name, 33 + scope = excluded.scope, 34 + color = excluded.color, 35 + multiple = excluded.multiple`, 36 + l.Did, 37 + l.Rkey, 38 + l.Name, 39 + l.ValueType.Type, 40 + l.ValueType.Format, 41 + strings.Join(l.ValueType.Enum, ","), 42 + strings.Join(l.Scope, ","), 43 + l.Color, 44 + l.Multiple, 45 + l.Created.Format(time.RFC3339), 46 + time.Now().Format(time.RFC3339), 47 + ) 48 + if err != nil { 49 + return 0, err 50 + } 51 + 52 + id, err := result.LastInsertId() 53 + if err != nil { 54 + return 0, err 55 + } 56 + 57 + l.Id = id 58 + 59 + return id, nil 60 + } 61 + 62 + func DeleteLabelDefinition(e Execer, filters ...filter) error { 63 + var conditions []string 64 + var args []any 65 + for _, filter := range filters { 66 + conditions = append(conditions, filter.Condition()) 67 + args = append(args, filter.Arg()...) 68 + } 69 + whereClause := "" 70 + if conditions != nil { 71 + whereClause = " where " + strings.Join(conditions, " and ") 72 + } 73 + query := fmt.Sprintf(`delete from label_definitions %s`, whereClause) 74 + _, err := e.Exec(query, args...) 75 + return err 76 + } 77 + 78 + func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) { 79 + var labelDefinitions []models.LabelDefinition 80 + var conditions []string 81 + var args []any 82 + 83 + for _, filter := range filters { 84 + conditions = append(conditions, filter.Condition()) 85 + args = append(args, filter.Arg()...) 86 + } 87 + 88 + whereClause := "" 89 + if conditions != nil { 90 + whereClause = " where " + strings.Join(conditions, " and ") 91 + } 92 + 93 + query := fmt.Sprintf( 94 + ` 95 + select 96 + id, 97 + did, 98 + rkey, 99 + name, 100 + value_type, 101 + value_format, 102 + value_enum, 103 + scope, 104 + color, 105 + multiple, 106 + created 107 + from label_definitions 108 + %s 109 + order by created 110 + `, 111 + whereClause, 112 + ) 113 + 114 + rows, err := e.Query(query, args...) 115 + if err != nil { 116 + return nil, err 117 + } 118 + defer rows.Close() 119 + 120 + for rows.Next() { 121 + var labelDefinition models.LabelDefinition 122 + var createdAt, enumVariants, scopes string 123 + var color sql.Null[string] 124 + var multiple int 125 + 126 + if err := rows.Scan( 127 + &labelDefinition.Id, 128 + &labelDefinition.Did, 129 + &labelDefinition.Rkey, 130 + &labelDefinition.Name, 131 + &labelDefinition.ValueType.Type, 132 + &labelDefinition.ValueType.Format, 133 + &enumVariants, 134 + &scopes, 135 + &color, 136 + &multiple, 137 + &createdAt, 138 + ); err != nil { 139 + return nil, err 140 + } 141 + 142 + labelDefinition.Created, err = time.Parse(time.RFC3339, createdAt) 143 + if err != nil { 144 + labelDefinition.Created = time.Now() 145 + } 146 + 147 + if color.Valid { 148 + labelDefinition.Color = &color.V 149 + } 150 + 151 + if multiple != 0 { 152 + labelDefinition.Multiple = true 153 + } 154 + 155 + if enumVariants != "" { 156 + labelDefinition.ValueType.Enum = strings.Split(enumVariants, ",") 157 + } 158 + 159 + for s := range strings.SplitSeq(scopes, ",") { 160 + labelDefinition.Scope = append(labelDefinition.Scope, s) 161 + } 162 + 163 + labelDefinitions = append(labelDefinitions, labelDefinition) 164 + } 165 + 166 + return labelDefinitions, nil 167 + } 168 + 169 + // helper to get exactly one label def 170 + func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) { 171 + labels, err := GetLabelDefinitions(e, filters...) 172 + if err != nil { 173 + return nil, err 174 + } 175 + 176 + if labels == nil { 177 + return nil, sql.ErrNoRows 178 + } 179 + 180 + if len(labels) != 1 { 181 + return nil, fmt.Errorf("too many rows returned") 182 + } 183 + 184 + return &labels[0], nil 185 + } 186 + 187 + func AddLabelOp(e Execer, l *models.LabelOp) (int64, error) { 188 + now := time.Now() 189 + result, err := e.Exec( 190 + `insert into label_ops ( 191 + did, 192 + rkey, 193 + subject, 194 + operation, 195 + operand_key, 196 + operand_value, 197 + performed, 198 + indexed 199 + ) 200 + values (?, ?, ?, ?, ?, ?, ?, ?) 201 + on conflict(did, rkey, subject, operand_key, operand_value) do update set 202 + operation = excluded.operation, 203 + operand_value = excluded.operand_value, 204 + performed = excluded.performed, 205 + indexed = excluded.indexed`, 206 + l.Did, 207 + l.Rkey, 208 + l.Subject.String(), 209 + string(l.Operation), 210 + l.OperandKey, 211 + l.OperandValue, 212 + l.PerformedAt.Format(time.RFC3339), 213 + now.Format(time.RFC3339), 214 + ) 215 + if err != nil { 216 + return 0, err 217 + } 218 + 219 + id, err := result.LastInsertId() 220 + if err != nil { 221 + return 0, err 222 + } 223 + 224 + l.Id = id 225 + l.IndexedAt = now 226 + 227 + return id, nil 228 + } 229 + 230 + func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) { 231 + var labelOps []models.LabelOp 232 + var conditions []string 233 + var args []any 234 + 235 + for _, filter := range filters { 236 + conditions = append(conditions, filter.Condition()) 237 + args = append(args, filter.Arg()...) 238 + } 239 + 240 + whereClause := "" 241 + if conditions != nil { 242 + whereClause = " where " + strings.Join(conditions, " and ") 243 + } 244 + 245 + query := fmt.Sprintf( 246 + ` 247 + select 248 + id, 249 + did, 250 + rkey, 251 + subject, 252 + operation, 253 + operand_key, 254 + operand_value, 255 + performed, 256 + indexed 257 + from label_ops 258 + %s 259 + order by indexed 260 + `, 261 + whereClause, 262 + ) 263 + 264 + rows, err := e.Query(query, args...) 265 + if err != nil { 266 + return nil, err 267 + } 268 + defer rows.Close() 269 + 270 + for rows.Next() { 271 + var labelOp models.LabelOp 272 + var performedAt, indexedAt string 273 + 274 + if err := rows.Scan( 275 + &labelOp.Id, 276 + &labelOp.Did, 277 + &labelOp.Rkey, 278 + &labelOp.Subject, 279 + &labelOp.Operation, 280 + &labelOp.OperandKey, 281 + &labelOp.OperandValue, 282 + &performedAt, 283 + &indexedAt, 284 + ); err != nil { 285 + return nil, err 286 + } 287 + 288 + labelOp.PerformedAt, err = time.Parse(time.RFC3339, performedAt) 289 + if err != nil { 290 + labelOp.PerformedAt = time.Now() 291 + } 292 + 293 + labelOp.IndexedAt, err = time.Parse(time.RFC3339, indexedAt) 294 + if err != nil { 295 + labelOp.IndexedAt = time.Now() 296 + } 297 + 298 + labelOps = append(labelOps, labelOp) 299 + } 300 + 301 + return labelOps, nil 302 + } 303 + 304 + // get labels for a given list of subject URIs 305 + func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) { 306 + ops, err := GetLabelOps(e, filters...) 307 + if err != nil { 308 + return nil, err 309 + } 310 + 311 + // group ops by subject 312 + opsBySubject := make(map[syntax.ATURI][]models.LabelOp) 313 + for _, op := range ops { 314 + subject := syntax.ATURI(op.Subject) 315 + opsBySubject[subject] = append(opsBySubject[subject], op) 316 + } 317 + 318 + // get all unique labelats for creating the context 319 + labelAtSet := make(map[string]bool) 320 + for _, op := range ops { 321 + labelAtSet[op.OperandKey] = true 322 + } 323 + labelAts := slices.Collect(maps.Keys(labelAtSet)) 324 + 325 + actx, err := NewLabelApplicationCtx(e, FilterIn("at_uri", labelAts)) 326 + if err != nil { 327 + return nil, err 328 + } 329 + 330 + // apply label ops for each subject and collect results 331 + results := make(map[syntax.ATURI]models.LabelState) 332 + for subject, subjectOps := range opsBySubject { 333 + state := models.NewLabelState() 334 + actx.ApplyLabelOps(state, subjectOps) 335 + results[subject] = state 336 + } 337 + 338 + return results, nil 339 + } 340 + 341 + func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) { 342 + labels, err := GetLabelDefinitions(e, filters...) 343 + if err != nil { 344 + return nil, err 345 + } 346 + 347 + defs := make(map[string]*models.LabelDefinition) 348 + for _, l := range labels { 349 + defs[l.AtUri().String()] = &l 350 + } 351 + 352 + return &models.LabelApplicationCtx{Defs: defs}, nil 353 + }
+38 -13
appview/db/language.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "strings" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/appview/models" 8 10 ) 9 11 10 - type RepoLanguage struct { 11 - Id int64 12 - RepoAt syntax.ATURI 13 - Ref string 14 - IsDefaultRef bool 15 - Language string 16 - Bytes int64 17 - } 18 - 19 - func GetRepoLanguages(e Execer, filters ...filter) ([]RepoLanguage, error) { 12 + func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) { 20 13 var conditions []string 21 14 var args []any 22 15 for _, filter := range filters { ··· 39 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 40 33 } 41 34 42 - var langs []RepoLanguage 35 + var langs []models.RepoLanguage 43 36 for rows.Next() { 44 - var rl RepoLanguage 37 + var rl models.RepoLanguage 45 38 var isDefaultRef int 46 39 47 40 err := rows.Scan( ··· 69 62 return langs, nil 70 63 } 71 64 72 - func InsertRepoLanguages(e Execer, langs []RepoLanguage) error { 65 + func InsertRepoLanguages(e Execer, langs []models.RepoLanguage) error { 73 66 stmt, err := e.Prepare( 74 67 "insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)", 75 68 ) ··· 91 84 92 85 return nil 93 86 } 87 + 88 + func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + var conditions []string 90 + var args []any 91 + for _, filter := range filters { 92 + conditions = append(conditions, filter.Condition()) 93 + args = append(args, filter.Arg()...) 94 + } 95 + 96 + whereClause := "" 97 + if conditions != nil { 98 + whereClause = " where " + strings.Join(conditions, " and ") 99 + } 100 + 101 + query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 + 103 + _, err := e.Exec(query, args...) 104 + return err 105 + } 106 + 107 + func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 + err := DeleteRepoLanguages( 109 + tx, 110 + FilterEq("repo_at", repoAt), 111 + FilterEq("ref", ref), 112 + ) 113 + if err != nil { 114 + return fmt.Errorf("failed to delete existing languages: %w", err) 115 + } 116 + 117 + return InsertRepoLanguages(tx, langs) 118 + }
+450
appview/db/notifications.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "strings" 9 + "time" 10 + 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/pagination" 13 + ) 14 + 15 + func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error { 16 + query := ` 17 + INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 18 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 19 + ` 20 + 21 + result, err := d.DB.ExecContext(ctx, query, 22 + notification.RecipientDid, 23 + notification.ActorDid, 24 + string(notification.Type), 25 + notification.EntityType, 26 + notification.EntityId, 27 + notification.Read, 28 + notification.RepoId, 29 + notification.IssueId, 30 + notification.PullId, 31 + ) 32 + if err != nil { 33 + return fmt.Errorf("failed to create notification: %w", err) 34 + } 35 + 36 + id, err := result.LastInsertId() 37 + if err != nil { 38 + return fmt.Errorf("failed to get notification ID: %w", err) 39 + } 40 + 41 + notification.ID = id 42 + return nil 43 + } 44 + 45 + // GetNotificationsPaginated retrieves notifications with filters and pagination 46 + func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) { 47 + var conditions []string 48 + var args []any 49 + 50 + for _, filter := range filters { 51 + conditions = append(conditions, filter.Condition()) 52 + args = append(args, filter.Arg()...) 53 + } 54 + 55 + whereClause := "" 56 + if len(conditions) > 0 { 57 + whereClause = "WHERE " + conditions[0] 58 + for _, condition := range conditions[1:] { 59 + whereClause += " AND " + condition 60 + } 61 + } 62 + 63 + query := fmt.Sprintf(` 64 + select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 65 + from notifications 66 + %s 67 + order by created desc 68 + limit ? offset ? 69 + `, whereClause) 70 + 71 + args = append(args, page.Limit, page.Offset) 72 + 73 + rows, err := e.QueryContext(context.Background(), query, args...) 74 + if err != nil { 75 + return nil, fmt.Errorf("failed to query notifications: %w", err) 76 + } 77 + defer rows.Close() 78 + 79 + var notifications []*models.Notification 80 + for rows.Next() { 81 + var n models.Notification 82 + var typeStr string 83 + var createdStr string 84 + err := rows.Scan( 85 + &n.ID, 86 + &n.RecipientDid, 87 + &n.ActorDid, 88 + &typeStr, 89 + &n.EntityType, 90 + &n.EntityId, 91 + &n.Read, 92 + &createdStr, 93 + &n.RepoId, 94 + &n.IssueId, 95 + &n.PullId, 96 + ) 97 + if err != nil { 98 + return nil, fmt.Errorf("failed to scan notification: %w", err) 99 + } 100 + n.Type = models.NotificationType(typeStr) 101 + n.Created, err = time.Parse(time.RFC3339, createdStr) 102 + if err != nil { 103 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 104 + } 105 + notifications = append(notifications, &n) 106 + } 107 + 108 + return notifications, nil 109 + } 110 + 111 + // GetNotificationsWithEntities retrieves notifications with their related entities 112 + func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) { 113 + var conditions []string 114 + var args []any 115 + 116 + for _, filter := range filters { 117 + conditions = append(conditions, filter.Condition()) 118 + args = append(args, filter.Arg()...) 119 + } 120 + 121 + whereClause := "" 122 + if len(conditions) > 0 { 123 + whereClause = "WHERE " + conditions[0] 124 + for _, condition := range conditions[1:] { 125 + whereClause += " AND " + condition 126 + } 127 + } 128 + 129 + query := fmt.Sprintf(` 130 + select 131 + n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 132 + n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 133 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 134 + i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open, 135 + p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state 136 + from notifications n 137 + left join repos r on n.repo_id = r.id 138 + left join issues i on n.issue_id = i.id 139 + left join pulls p on n.pull_id = p.id 140 + %s 141 + order by n.created desc 142 + limit ? offset ? 143 + `, whereClause) 144 + 145 + args = append(args, page.Limit, page.Offset) 146 + 147 + rows, err := e.QueryContext(context.Background(), query, args...) 148 + if err != nil { 149 + return nil, fmt.Errorf("failed to query notifications with entities: %w", err) 150 + } 151 + defer rows.Close() 152 + 153 + var notifications []*models.NotificationWithEntity 154 + for rows.Next() { 155 + var n models.Notification 156 + var typeStr string 157 + var createdStr string 158 + var repo models.Repo 159 + var issue models.Issue 160 + var pull models.Pull 161 + var rId, iId, pId sql.NullInt64 162 + var rDid, rName, rDescription sql.NullString 163 + var iDid sql.NullString 164 + var iIssueId sql.NullInt64 165 + var iTitle sql.NullString 166 + var iOpen sql.NullBool 167 + var pOwnerDid sql.NullString 168 + var pPullId sql.NullInt64 169 + var pTitle sql.NullString 170 + var pState sql.NullInt64 171 + 172 + err := rows.Scan( 173 + &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 174 + &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 175 + &rId, &rDid, &rName, &rDescription, 176 + &iId, &iDid, &iIssueId, &iTitle, &iOpen, 177 + &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 178 + ) 179 + if err != nil { 180 + return nil, fmt.Errorf("failed to scan notification with entities: %w", err) 181 + } 182 + 183 + n.Type = models.NotificationType(typeStr) 184 + n.Created, err = time.Parse(time.RFC3339, createdStr) 185 + if err != nil { 186 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 187 + } 188 + 189 + nwe := &models.NotificationWithEntity{Notification: &n} 190 + 191 + // populate repo if present 192 + if rId.Valid { 193 + repo.Id = rId.Int64 194 + if rDid.Valid { 195 + repo.Did = rDid.String 196 + } 197 + if rName.Valid { 198 + repo.Name = rName.String 199 + } 200 + if rDescription.Valid { 201 + repo.Description = rDescription.String 202 + } 203 + nwe.Repo = &repo 204 + } 205 + 206 + // populate issue if present 207 + if iId.Valid { 208 + issue.Id = iId.Int64 209 + if iDid.Valid { 210 + issue.Did = iDid.String 211 + } 212 + if iIssueId.Valid { 213 + issue.IssueId = int(iIssueId.Int64) 214 + } 215 + if iTitle.Valid { 216 + issue.Title = iTitle.String 217 + } 218 + if iOpen.Valid { 219 + issue.Open = iOpen.Bool 220 + } 221 + nwe.Issue = &issue 222 + } 223 + 224 + // populate pull if present 225 + if pId.Valid { 226 + pull.ID = int(pId.Int64) 227 + if pOwnerDid.Valid { 228 + pull.OwnerDid = pOwnerDid.String 229 + } 230 + if pPullId.Valid { 231 + pull.PullId = int(pPullId.Int64) 232 + } 233 + if pTitle.Valid { 234 + pull.Title = pTitle.String 235 + } 236 + if pState.Valid { 237 + pull.State = models.PullState(pState.Int64) 238 + } 239 + nwe.Pull = &pull 240 + } 241 + 242 + notifications = append(notifications, nwe) 243 + } 244 + 245 + return notifications, nil 246 + } 247 + 248 + // GetNotifications retrieves notifications with filters 249 + func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { 250 + return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 251 + } 252 + 253 + func CountNotifications(e Execer, filters ...filter) (int64, error) { 254 + var conditions []string 255 + var args []any 256 + for _, filter := range filters { 257 + conditions = append(conditions, filter.Condition()) 258 + args = append(args, filter.Arg()...) 259 + } 260 + 261 + whereClause := "" 262 + if conditions != nil { 263 + whereClause = " where " + strings.Join(conditions, " and ") 264 + } 265 + 266 + query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) 267 + var count int64 268 + err := e.QueryRow(query, args...).Scan(&count) 269 + 270 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 271 + return 0, err 272 + } 273 + 274 + return count, nil 275 + } 276 + 277 + func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { 278 + idFilter := FilterEq("id", notificationID) 279 + recipientFilter := FilterEq("recipient_did", userDID) 280 + 281 + query := fmt.Sprintf(` 282 + UPDATE notifications 283 + SET read = 1 284 + WHERE %s AND %s 285 + `, idFilter.Condition(), recipientFilter.Condition()) 286 + 287 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 288 + 289 + result, err := d.DB.ExecContext(ctx, query, args...) 290 + if err != nil { 291 + return fmt.Errorf("failed to mark notification as read: %w", err) 292 + } 293 + 294 + rowsAffected, err := result.RowsAffected() 295 + if err != nil { 296 + return fmt.Errorf("failed to get rows affected: %w", err) 297 + } 298 + 299 + if rowsAffected == 0 { 300 + return fmt.Errorf("notification not found or access denied") 301 + } 302 + 303 + return nil 304 + } 305 + 306 + func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { 307 + recipientFilter := FilterEq("recipient_did", userDID) 308 + readFilter := FilterEq("read", 0) 309 + 310 + query := fmt.Sprintf(` 311 + UPDATE notifications 312 + SET read = 1 313 + WHERE %s AND %s 314 + `, recipientFilter.Condition(), readFilter.Condition()) 315 + 316 + args := append(recipientFilter.Arg(), readFilter.Arg()...) 317 + 318 + _, err := d.DB.ExecContext(ctx, query, args...) 319 + if err != nil { 320 + return fmt.Errorf("failed to mark all notifications as read: %w", err) 321 + } 322 + 323 + return nil 324 + } 325 + 326 + func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { 327 + idFilter := FilterEq("id", notificationID) 328 + recipientFilter := FilterEq("recipient_did", userDID) 329 + 330 + query := fmt.Sprintf(` 331 + DELETE FROM notifications 332 + WHERE %s AND %s 333 + `, idFilter.Condition(), recipientFilter.Condition()) 334 + 335 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 336 + 337 + result, err := d.DB.ExecContext(ctx, query, args...) 338 + if err != nil { 339 + return fmt.Errorf("failed to delete notification: %w", err) 340 + } 341 + 342 + rowsAffected, err := result.RowsAffected() 343 + if err != nil { 344 + return fmt.Errorf("failed to get rows affected: %w", err) 345 + } 346 + 347 + if rowsAffected == 0 { 348 + return fmt.Errorf("notification not found or access denied") 349 + } 350 + 351 + return nil 352 + } 353 + 354 + func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { 355 + userFilter := FilterEq("user_did", userDID) 356 + 357 + query := fmt.Sprintf(` 358 + SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created, 359 + pull_commented, followed, pull_merged, issue_closed, email_notifications 360 + FROM notification_preferences 361 + WHERE %s 362 + `, userFilter.Condition()) 363 + 364 + var prefs models.NotificationPreferences 365 + err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan( 366 + &prefs.ID, 367 + &prefs.UserDid, 368 + &prefs.RepoStarred, 369 + &prefs.IssueCreated, 370 + &prefs.IssueCommented, 371 + &prefs.PullCreated, 372 + &prefs.PullCommented, 373 + &prefs.Followed, 374 + &prefs.PullMerged, 375 + &prefs.IssueClosed, 376 + &prefs.EmailNotifications, 377 + ) 378 + 379 + if err != nil { 380 + if err == sql.ErrNoRows { 381 + return &models.NotificationPreferences{ 382 + UserDid: userDID, 383 + RepoStarred: true, 384 + IssueCreated: true, 385 + IssueCommented: true, 386 + PullCreated: true, 387 + PullCommented: true, 388 + Followed: true, 389 + PullMerged: true, 390 + IssueClosed: true, 391 + EmailNotifications: false, 392 + }, nil 393 + } 394 + return nil, fmt.Errorf("failed to get notification preferences: %w", err) 395 + } 396 + 397 + return &prefs, nil 398 + } 399 + 400 + func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { 401 + query := ` 402 + INSERT OR REPLACE INTO notification_preferences 403 + (user_did, repo_starred, issue_created, issue_commented, pull_created, 404 + pull_commented, followed, pull_merged, issue_closed, email_notifications) 405 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 406 + ` 407 + 408 + result, err := d.DB.ExecContext(ctx, query, 409 + prefs.UserDid, 410 + prefs.RepoStarred, 411 + prefs.IssueCreated, 412 + prefs.IssueCommented, 413 + prefs.PullCreated, 414 + prefs.PullCommented, 415 + prefs.Followed, 416 + prefs.PullMerged, 417 + prefs.IssueClosed, 418 + prefs.EmailNotifications, 419 + ) 420 + if err != nil { 421 + return fmt.Errorf("failed to update notification preferences: %w", err) 422 + } 423 + 424 + if prefs.ID == 0 { 425 + id, err := result.LastInsertId() 426 + if err != nil { 427 + return fmt.Errorf("failed to get preferences ID: %w", err) 428 + } 429 + prefs.ID = id 430 + } 431 + 432 + return nil 433 + } 434 + 435 + func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 436 + cutoff := time.Now().Add(-olderThan) 437 + createdFilter := FilterLte("created", cutoff) 438 + 439 + query := fmt.Sprintf(` 440 + DELETE FROM notifications 441 + WHERE %s 442 + `, createdFilter.Condition()) 443 + 444 + _, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...) 445 + if err != nil { 446 + return fmt.Errorf("failed to cleanup old notifications: %w", err) 447 + } 448 + 449 + return nil 450 + }
-173
appview/db/oauth.go
··· 1 - package db 2 - 3 - type OAuthRequest struct { 4 - ID uint 5 - AuthserverIss string 6 - Handle string 7 - State string 8 - Did string 9 - PdsUrl string 10 - PkceVerifier string 11 - DpopAuthserverNonce string 12 - DpopPrivateJwk string 13 - } 14 - 15 - func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error { 16 - _, err := e.Exec(` 17 - insert into oauth_requests ( 18 - auth_server_iss, 19 - state, 20 - handle, 21 - did, 22 - pds_url, 23 - pkce_verifier, 24 - dpop_auth_server_nonce, 25 - dpop_private_jwk 26 - ) values (?, ?, ?, ?, ?, ?, ?, ?)`, 27 - oauthRequest.AuthserverIss, 28 - oauthRequest.State, 29 - oauthRequest.Handle, 30 - oauthRequest.Did, 31 - oauthRequest.PdsUrl, 32 - oauthRequest.PkceVerifier, 33 - oauthRequest.DpopAuthserverNonce, 34 - oauthRequest.DpopPrivateJwk, 35 - ) 36 - return err 37 - } 38 - 39 - func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) { 40 - var req OAuthRequest 41 - err := e.QueryRow(` 42 - select 43 - id, 44 - auth_server_iss, 45 - handle, 46 - state, 47 - did, 48 - pds_url, 49 - pkce_verifier, 50 - dpop_auth_server_nonce, 51 - dpop_private_jwk 52 - from oauth_requests 53 - where state = ?`, state).Scan( 54 - &req.ID, 55 - &req.AuthserverIss, 56 - &req.Handle, 57 - &req.State, 58 - &req.Did, 59 - &req.PdsUrl, 60 - &req.PkceVerifier, 61 - &req.DpopAuthserverNonce, 62 - &req.DpopPrivateJwk, 63 - ) 64 - return req, err 65 - } 66 - 67 - func DeleteOAuthRequestByState(e Execer, state string) error { 68 - _, err := e.Exec(` 69 - delete from oauth_requests 70 - where state = ?`, state) 71 - return err 72 - } 73 - 74 - type OAuthSession struct { 75 - ID uint 76 - Handle string 77 - Did string 78 - PdsUrl string 79 - AccessJwt string 80 - RefreshJwt string 81 - AuthServerIss string 82 - DpopPdsNonce string 83 - DpopAuthserverNonce string 84 - DpopPrivateJwk string 85 - Expiry string 86 - } 87 - 88 - func SaveOAuthSession(e Execer, session OAuthSession) error { 89 - _, err := e.Exec(` 90 - insert into oauth_sessions ( 91 - did, 92 - handle, 93 - pds_url, 94 - access_jwt, 95 - refresh_jwt, 96 - auth_server_iss, 97 - dpop_auth_server_nonce, 98 - dpop_private_jwk, 99 - expiry 100 - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 101 - session.Did, 102 - session.Handle, 103 - session.PdsUrl, 104 - session.AccessJwt, 105 - session.RefreshJwt, 106 - session.AuthServerIss, 107 - session.DpopAuthserverNonce, 108 - session.DpopPrivateJwk, 109 - session.Expiry, 110 - ) 111 - return err 112 - } 113 - 114 - func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error { 115 - _, err := e.Exec(` 116 - update oauth_sessions 117 - set access_jwt = ?, refresh_jwt = ?, expiry = ? 118 - where did = ?`, 119 - accessJwt, 120 - refreshJwt, 121 - expiry, 122 - did, 123 - ) 124 - return err 125 - } 126 - 127 - func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) { 128 - var session OAuthSession 129 - err := e.QueryRow(` 130 - select 131 - id, 132 - did, 133 - handle, 134 - pds_url, 135 - access_jwt, 136 - refresh_jwt, 137 - auth_server_iss, 138 - dpop_auth_server_nonce, 139 - dpop_private_jwk, 140 - expiry 141 - from oauth_sessions 142 - where did = ?`, did).Scan( 143 - &session.ID, 144 - &session.Did, 145 - &session.Handle, 146 - &session.PdsUrl, 147 - &session.AccessJwt, 148 - &session.RefreshJwt, 149 - &session.AuthServerIss, 150 - &session.DpopAuthserverNonce, 151 - &session.DpopPrivateJwk, 152 - &session.Expiry, 153 - ) 154 - return &session, err 155 - } 156 - 157 - func DeleteOAuthSessionByDid(e Execer, did string) error { 158 - _, err := e.Exec(` 159 - delete from oauth_sessions 160 - where did = ?`, did) 161 - return err 162 - } 163 - 164 - func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error { 165 - _, err := e.Exec(` 166 - update oauth_sessions 167 - set dpop_pds_nonce = ? 168 - where did = ?`, 169 - dpopPdsNonce, 170 - did, 171 - ) 172 - return err 173 - }
+17 -139
appview/db/pipeline.go
··· 6 6 "strings" 7 7 "time" 8 8 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/go-git/go-git/v5/plumbing" 11 - spindle "tangled.sh/tangled.sh/core/spindle/models" 12 - "tangled.sh/tangled.sh/core/workflow" 9 + "tangled.org/core/appview/models" 13 10 ) 14 11 15 - type Pipeline struct { 16 - Id int 17 - Rkey string 18 - Knot string 19 - RepoOwner syntax.DID 20 - RepoName string 21 - TriggerId int 22 - Sha string 23 - Created time.Time 24 - 25 - // populate when querying for reverse mappings 26 - Trigger *Trigger 27 - Statuses map[string]WorkflowStatus 28 - } 29 - 30 - type WorkflowStatus struct { 31 - Data []PipelineStatus 32 - } 33 - 34 - func (w WorkflowStatus) Latest() PipelineStatus { 35 - return w.Data[len(w.Data)-1] 36 - } 37 - 38 - // time taken by this workflow to reach an "end state" 39 - func (w WorkflowStatus) TimeTaken() time.Duration { 40 - var start, end *time.Time 41 - for _, s := range w.Data { 42 - if s.Status.IsStart() { 43 - start = &s.Created 44 - } 45 - if s.Status.IsFinish() { 46 - end = &s.Created 47 - } 48 - } 49 - 50 - if start != nil && end != nil && end.After(*start) { 51 - return end.Sub(*start) 52 - } 53 - 54 - return 0 55 - } 56 - 57 - func (p Pipeline) Counts() map[string]int { 58 - m := make(map[string]int) 59 - for _, w := range p.Statuses { 60 - m[w.Latest().Status.String()] += 1 61 - } 62 - return m 63 - } 64 - 65 - func (p Pipeline) TimeTaken() time.Duration { 66 - var s time.Duration 67 - for _, w := range p.Statuses { 68 - s += w.TimeTaken() 69 - } 70 - return s 71 - } 72 - 73 - func (p Pipeline) Workflows() []string { 74 - var ws []string 75 - for v := range p.Statuses { 76 - ws = append(ws, v) 77 - } 78 - slices.Sort(ws) 79 - return ws 80 - } 81 - 82 - // if we know that a spindle has picked up this pipeline, then it is Responding 83 - func (p Pipeline) IsResponding() bool { 84 - return len(p.Statuses) != 0 85 - } 86 - 87 - type Trigger struct { 88 - Id int 89 - Kind workflow.TriggerKind 90 - 91 - // push trigger fields 92 - PushRef *string 93 - PushNewSha *string 94 - PushOldSha *string 95 - 96 - // pull request trigger fields 97 - PRSourceBranch *string 98 - PRTargetBranch *string 99 - PRSourceSha *string 100 - PRAction *string 101 - } 102 - 103 - func (t *Trigger) IsPush() bool { 104 - return t != nil && t.Kind == workflow.TriggerKindPush 105 - } 106 - 107 - func (t *Trigger) IsPullRequest() bool { 108 - return t != nil && t.Kind == workflow.TriggerKindPullRequest 109 - } 110 - 111 - func (t *Trigger) TargetRef() string { 112 - if t.IsPush() { 113 - return plumbing.ReferenceName(*t.PushRef).Short() 114 - } else if t.IsPullRequest() { 115 - return *t.PRTargetBranch 116 - } 117 - 118 - return "" 119 - } 120 - 121 - type PipelineStatus struct { 122 - ID int 123 - Spindle string 124 - Rkey string 125 - PipelineKnot string 126 - PipelineRkey string 127 - Created time.Time 128 - Workflow string 129 - Status spindle.StatusKind 130 - Error *string 131 - ExitCode int 132 - } 133 - 134 - func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) { 135 - var pipelines []Pipeline 12 + func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) { 13 + var pipelines []models.Pipeline 136 14 137 15 var conditions []string 138 16 var args []any ··· 156 34 defer rows.Close() 157 35 158 36 for rows.Next() { 159 - var pipeline Pipeline 37 + var pipeline models.Pipeline 160 38 var createdAt string 161 39 err = rows.Scan( 162 40 &pipeline.Id, ··· 185 63 return pipelines, nil 186 64 } 187 65 188 - func AddPipeline(e Execer, pipeline Pipeline) error { 66 + func AddPipeline(e Execer, pipeline models.Pipeline) error { 189 67 args := []any{ 190 68 pipeline.Rkey, 191 69 pipeline.Knot, ··· 216 94 return err 217 95 } 218 96 219 - func AddTrigger(e Execer, trigger Trigger) (int64, error) { 97 + func AddTrigger(e Execer, trigger models.Trigger) (int64, error) { 220 98 args := []any{ 221 99 trigger.Kind, 222 100 trigger.PushRef, ··· 252 130 return res.LastInsertId() 253 131 } 254 132 255 - func AddPipelineStatus(e Execer, status PipelineStatus) error { 133 + func AddPipelineStatus(e Execer, status models.PipelineStatus) error { 256 134 args := []any{ 257 135 status.Spindle, 258 136 status.Rkey, ··· 290 168 291 169 // this is a mega query, but the most useful one: 292 170 // get N pipelines, for each one get the latest status of its N workflows 293 - func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) { 171 + func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 294 172 var conditions []string 295 173 var args []any 296 174 for _, filter := range filters { ··· 335 213 } 336 214 defer rows.Close() 337 215 338 - pipelines := make(map[string]Pipeline) 216 + pipelines := make(map[string]models.Pipeline) 339 217 for rows.Next() { 340 - var p Pipeline 341 - var t Trigger 218 + var p models.Pipeline 219 + var t models.Trigger 342 220 var created string 343 221 344 222 err := rows.Scan( ··· 370 248 371 249 t.Id = p.TriggerId 372 250 p.Trigger = &t 373 - p.Statuses = make(map[string]WorkflowStatus) 251 + p.Statuses = make(map[string]models.WorkflowStatus) 374 252 375 253 k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 376 254 pipelines[k] = p ··· 409 287 defer rows.Close() 410 288 411 289 for rows.Next() { 412 - var ps PipelineStatus 290 + var ps models.PipelineStatus 413 291 var created string 414 292 415 293 err := rows.Scan( ··· 442 320 } 443 321 statuses, _ := pipeline.Statuses[ps.Workflow] 444 322 if !ok { 445 - pipeline.Statuses[ps.Workflow] = WorkflowStatus{} 323 + pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{} 446 324 } 447 325 448 326 // append ··· 453 331 pipelines[key] = pipeline 454 332 } 455 333 456 - var all []Pipeline 334 + var all []models.Pipeline 457 335 for _, p := range pipelines { 458 336 for _, s := range p.Statuses { 459 - slices.SortFunc(s.Data, func(a, b PipelineStatus) int { 337 + slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int { 460 338 if a.Created.After(b.Created) { 461 339 return 1 462 340 } ··· 476 354 } 477 355 478 356 // sort pipelines by date 479 - slices.SortFunc(all, func(a, b Pipeline) int { 357 + slices.SortFunc(all, func(a, b models.Pipeline) int { 480 358 if a.Created.After(b.Created) { 481 359 return -1 482 360 }
+34 -185
appview/db/profile.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.org/core/appview/models" 14 14 ) 15 15 16 - type RepoEvent struct { 17 - Repo *Repo 18 - Source *Repo 19 - } 20 - 21 - type ProfileTimeline struct { 22 - ByMonth []ByMonth 23 - } 24 - 25 - type ByMonth struct { 26 - RepoEvents []RepoEvent 27 - IssueEvents IssueEvents 28 - PullEvents PullEvents 29 - } 30 - 31 - func (b ByMonth) IsEmpty() bool { 32 - return len(b.RepoEvents) == 0 && 33 - len(b.IssueEvents.Items) == 0 && 34 - len(b.PullEvents.Items) == 0 35 - } 36 - 37 - type IssueEvents struct { 38 - Items []*Issue 39 - } 40 - 41 - type IssueEventStats struct { 42 - Open int 43 - Closed int 44 - } 45 - 46 - func (i IssueEvents) Stats() IssueEventStats { 47 - var open, closed int 48 - for _, issue := range i.Items { 49 - if issue.Open { 50 - open += 1 51 - } else { 52 - closed += 1 53 - } 54 - } 55 - 56 - return IssueEventStats{ 57 - Open: open, 58 - Closed: closed, 59 - } 60 - } 61 - 62 - type PullEvents struct { 63 - Items []*Pull 64 - } 65 - 66 - func (p PullEvents) Stats() PullEventStats { 67 - var open, merged, closed int 68 - for _, pull := range p.Items { 69 - switch pull.State { 70 - case PullOpen: 71 - open += 1 72 - case PullMerged: 73 - merged += 1 74 - case PullClosed: 75 - closed += 1 76 - } 77 - } 78 - 79 - return PullEventStats{ 80 - Open: open, 81 - Merged: merged, 82 - Closed: closed, 83 - } 84 - } 85 - 86 - type PullEventStats struct { 87 - Closed int 88 - Open int 89 - Merged int 90 - } 91 - 92 16 const TimeframeMonths = 7 93 17 94 - func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { 95 - timeline := ProfileTimeline{ 96 - ByMonth: make([]ByMonth, TimeframeMonths), 18 + func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) { 19 + timeline := models.ProfileTimeline{ 20 + ByMonth: make([]models.ByMonth, TimeframeMonths), 97 21 } 98 22 currentMonth := time.Now().Month() 99 23 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) ··· 118 42 *items = append(*items, &pull) 119 43 } 120 44 121 - issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) 45 + issues, err := GetIssues( 46 + e, 47 + FilterEq("did", forDid), 48 + FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 49 + ) 122 50 if err != nil { 123 51 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 124 52 } ··· 137 65 *items = append(*items, &issue) 138 66 } 139 67 140 - repos, err := GetAllReposByDid(e, forDid) 68 + repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 141 69 if err != nil { 142 70 return nil, fmt.Errorf("error getting all repos by did: %w", err) 143 71 } 144 72 145 73 for _, repo := range repos { 146 74 // TODO: get this in the original query; requires COALESCE because nullable 147 - var sourceRepo *Repo 75 + var sourceRepo *models.Repo 148 76 if repo.Source != "" { 149 77 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 150 78 if err != nil { ··· 162 90 idx := currentMonth - repoMonth 163 91 164 92 items := &timeline.ByMonth[idx].RepoEvents 165 - *items = append(*items, RepoEvent{ 93 + *items = append(*items, models.RepoEvent{ 166 94 Repo: &repo, 167 95 Source: sourceRepo, 168 96 }) ··· 171 99 return &timeline, nil 172 100 } 173 101 174 - type Profile struct { 175 - // ids 176 - ID int 177 - Did string 178 - 179 - // data 180 - Description string 181 - IncludeBluesky bool 182 - Location string 183 - Links [5]string 184 - Stats [2]VanityStat 185 - PinnedRepos [6]syntax.ATURI 186 - } 187 - 188 - func (p Profile) IsLinksEmpty() bool { 189 - for _, l := range p.Links { 190 - if l != "" { 191 - return false 192 - } 193 - } 194 - return true 195 - } 196 - 197 - func (p Profile) IsStatsEmpty() bool { 198 - for _, s := range p.Stats { 199 - if s.Kind != "" { 200 - return false 201 - } 202 - } 203 - return true 204 - } 205 - 206 - func (p Profile) IsPinnedReposEmpty() bool { 207 - for _, r := range p.PinnedRepos { 208 - if r != "" { 209 - return false 210 - } 211 - } 212 - return true 213 - } 214 - 215 - type VanityStatKind string 216 - 217 - const ( 218 - VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" 219 - VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" 220 - VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" 221 - VanityStatOpenIssueCount VanityStatKind = "open-issue-count" 222 - VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 223 - VanityStatRepositoryCount VanityStatKind = "repository-count" 224 - ) 225 - 226 - func (v VanityStatKind) String() string { 227 - switch v { 228 - case VanityStatMergedPRCount: 229 - return "Merged PRs" 230 - case VanityStatClosedPRCount: 231 - return "Closed PRs" 232 - case VanityStatOpenPRCount: 233 - return "Open PRs" 234 - case VanityStatOpenIssueCount: 235 - return "Open Issues" 236 - case VanityStatClosedIssueCount: 237 - return "Closed Issues" 238 - case VanityStatRepositoryCount: 239 - return "Repositories" 240 - } 241 - return "" 242 - } 243 - 244 - type VanityStat struct { 245 - Kind VanityStatKind 246 - Value uint64 247 - } 248 - 249 - func (p *Profile) ProfileAt() syntax.ATURI { 250 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) 251 - } 252 - 253 - func UpsertProfile(tx *sql.Tx, profile *Profile) error { 102 + func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 254 103 defer tx.Rollback() 255 104 256 105 // update links ··· 348 197 return tx.Commit() 349 198 } 350 199 351 - func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 200 + func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) { 352 201 var conditions []string 353 202 var args []any 354 203 for _, filter := range filters { ··· 378 227 return nil, err 379 228 } 380 229 381 - profileMap := make(map[string]*Profile) 230 + profileMap := make(map[string]*models.Profile) 382 231 for rows.Next() { 383 - var profile Profile 232 + var profile models.Profile 384 233 var includeBluesky int 385 234 386 235 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) ··· 451 300 return profileMap, nil 452 301 } 453 302 454 - func GetProfile(e Execer, did string) (*Profile, error) { 455 - var profile Profile 303 + func GetProfile(e Execer, did string) (*models.Profile, error) { 304 + var profile models.Profile 456 305 profile.Did = did 457 306 458 307 includeBluesky := 0 ··· 461 310 did, 462 311 ).Scan(&profile.Description, &includeBluesky, &profile.Location) 463 312 if err == sql.ErrNoRows { 464 - profile := Profile{} 313 + profile := models.Profile{} 465 314 profile.Did = did 466 315 return &profile, nil 467 316 } ··· 521 370 return &profile, nil 522 371 } 523 372 524 - func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) { 373 + func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) { 525 374 query := "" 526 375 var args []any 527 376 switch stat { 528 - case VanityStatMergedPRCount: 377 + case models.VanityStatMergedPRCount: 529 378 query = `select count(id) from pulls where owner_did = ? and state = ?` 530 - args = append(args, did, PullMerged) 531 - case VanityStatClosedPRCount: 379 + args = append(args, did, models.PullMerged) 380 + case models.VanityStatClosedPRCount: 532 381 query = `select count(id) from pulls where owner_did = ? and state = ?` 533 - args = append(args, did, PullClosed) 534 - case VanityStatOpenPRCount: 382 + args = append(args, did, models.PullClosed) 383 + case models.VanityStatOpenPRCount: 535 384 query = `select count(id) from pulls where owner_did = ? and state = ?` 536 - args = append(args, did, PullOpen) 537 - case VanityStatOpenIssueCount: 538 - query = `select count(id) from issues where owner_did = ? and open = 1` 385 + args = append(args, did, models.PullOpen) 386 + case models.VanityStatOpenIssueCount: 387 + query = `select count(id) from issues where did = ? and open = 1` 539 388 args = append(args, did) 540 - case VanityStatClosedIssueCount: 541 - query = `select count(id) from issues where owner_did = ? and open = 0` 389 + case models.VanityStatClosedIssueCount: 390 + query = `select count(id) from issues where did = ? and open = 0` 542 391 args = append(args, did) 543 - case VanityStatRepositoryCount: 392 + case models.VanityStatRepositoryCount: 544 393 query = `select count(id) from repos where did = ?` 545 394 args = append(args, did) 546 395 } ··· 554 403 return result, nil 555 404 } 556 405 557 - func ValidateProfile(e Execer, profile *Profile) error { 406 + func ValidateProfile(e Execer, profile *models.Profile) error { 558 407 // ensure description is not too long 559 408 if len(profile.Description) > 256 { 560 409 return fmt.Errorf("Entered bio is too long.") ··· 572 421 } 573 422 574 423 // ensure all pinned repos are either own repos or collaborating repos 575 - repos, err := GetAllReposByDid(e, profile.Did) 424 + repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 576 425 if err != nil { 577 426 log.Printf("getting repos for %s: %s", profile.Did, err) 578 427 } ··· 602 451 return nil 603 452 } 604 453 605 - func validateLinks(profile *Profile) error { 454 + func validateLinks(profile *models.Profile) error { 606 455 for i, link := range profile.Links { 607 456 if link == "" { 608 457 continue
+7 -26
appview/db/pubkeys.go
··· 1 1 package db 2 2 3 3 import ( 4 - "encoding/json" 4 + "tangled.org/core/appview/models" 5 5 "time" 6 6 ) 7 7 ··· 29 29 return err 30 30 } 31 31 32 - type PublicKey struct { 33 - Did string `json:"did"` 34 - Key string `json:"key"` 35 - Name string `json:"name"` 36 - Rkey string `json:"rkey"` 37 - Created *time.Time 38 - } 39 - 40 - func (p PublicKey) MarshalJSON() ([]byte, error) { 41 - type Alias PublicKey 42 - return json.Marshal(&struct { 43 - Created string `json:"created"` 44 - *Alias 45 - }{ 46 - Created: p.Created.Format(time.RFC3339), 47 - Alias: (*Alias)(&p), 48 - }) 49 - } 50 - 51 - func GetAllPublicKeys(e Execer) ([]PublicKey, error) { 52 - var keys []PublicKey 32 + func GetAllPublicKeys(e Execer) ([]models.PublicKey, error) { 33 + var keys []models.PublicKey 53 34 54 35 rows, err := e.Query(`select key, name, did, rkey, created from public_keys`) 55 36 if err != nil { ··· 58 39 defer rows.Close() 59 40 60 41 for rows.Next() { 61 - var publicKey PublicKey 42 + var publicKey models.PublicKey 62 43 var createdAt string 63 44 if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil { 64 45 return nil, err ··· 75 56 return keys, nil 76 57 } 77 58 78 - func GetPublicKeysForDid(e Execer, did string) ([]PublicKey, error) { 79 - var keys []PublicKey 59 + func GetPublicKeysForDid(e Execer, did string) ([]models.PublicKey, error) { 60 + var keys []models.PublicKey 80 61 81 62 rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did) 82 63 if err != nil { ··· 85 66 defer rows.Close() 86 67 87 68 for rows.Next() { 88 - var publicKey PublicKey 69 + var publicKey models.PublicKey 89 70 var createdAt string 90 71 if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil { 91 72 return nil, err
+193 -572
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 + "cmp" 4 5 "database/sql" 6 + "errors" 5 7 "fmt" 6 - "log" 8 + "maps" 7 9 "slices" 8 10 "sort" 9 11 "strings" 10 12 "time" 11 13 12 14 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/patchutil" 15 - "tangled.sh/tangled.sh/core/types" 16 - ) 17 - 18 - type PullState int 19 - 20 - const ( 21 - PullClosed PullState = iota 22 - PullOpen 23 - PullMerged 24 - PullDeleted 15 + "tangled.org/core/appview/models" 25 16 ) 26 17 27 - func (p PullState) String() string { 28 - switch p { 29 - case PullOpen: 30 - return "open" 31 - case PullMerged: 32 - return "merged" 33 - case PullClosed: 34 - return "closed" 35 - case PullDeleted: 36 - return "deleted" 37 - default: 38 - return "closed" 39 - } 40 - } 41 - 42 - func (p PullState) IsOpen() bool { 43 - return p == PullOpen 44 - } 45 - func (p PullState) IsMerged() bool { 46 - return p == PullMerged 47 - } 48 - func (p PullState) IsClosed() bool { 49 - return p == PullClosed 50 - } 51 - func (p PullState) IsDeleted() bool { 52 - return p == PullDeleted 53 - } 54 - 55 - type Pull struct { 56 - // ids 57 - ID int 58 - PullId int 59 - 60 - // at ids 61 - RepoAt syntax.ATURI 62 - OwnerDid string 63 - Rkey string 64 - 65 - // content 66 - Title string 67 - Body string 68 - TargetBranch string 69 - State PullState 70 - Submissions []*PullSubmission 71 - 72 - // stacking 73 - StackId string // nullable string 74 - ChangeId string // nullable string 75 - ParentChangeId string // nullable string 76 - 77 - // meta 78 - Created time.Time 79 - PullSource *PullSource 80 - 81 - // optionally, populate this when querying for reverse mappings 82 - Repo *Repo 83 - } 84 - 85 - func (p Pull) AsRecord() tangled.RepoPull { 86 - var source *tangled.RepoPull_Source 87 - if p.PullSource != nil { 88 - s := p.PullSource.AsRecord() 89 - source = &s 90 - source.Sha = p.LatestSha() 91 - } 92 - 93 - record := tangled.RepoPull{ 94 - Title: p.Title, 95 - Body: &p.Body, 96 - CreatedAt: p.Created.Format(time.RFC3339), 97 - Target: &tangled.RepoPull_Target{ 98 - Repo: p.RepoAt.String(), 99 - Branch: p.TargetBranch, 100 - }, 101 - Patch: p.LatestPatch(), 102 - Source: source, 103 - } 104 - return record 105 - } 106 - 107 - type PullSource struct { 108 - Branch string 109 - RepoAt *syntax.ATURI 110 - 111 - // optionally populate this for reverse mappings 112 - Repo *Repo 113 - } 114 - 115 - func (p PullSource) AsRecord() tangled.RepoPull_Source { 116 - var repoAt *string 117 - if p.RepoAt != nil { 118 - s := p.RepoAt.String() 119 - repoAt = &s 120 - } 121 - record := tangled.RepoPull_Source{ 122 - Branch: p.Branch, 123 - Repo: repoAt, 124 - } 125 - return record 126 - } 127 - 128 - type PullSubmission struct { 129 - // ids 130 - ID int 131 - PullId int 132 - 133 - // at ids 134 - RepoAt syntax.ATURI 135 - 136 - // content 137 - RoundNumber int 138 - Patch string 139 - Comments []PullComment 140 - SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 141 - 142 - // meta 143 - Created time.Time 144 - } 145 - 146 - type PullComment struct { 147 - // ids 148 - ID int 149 - PullId int 150 - SubmissionId int 151 - 152 - // at ids 153 - RepoAt string 154 - OwnerDid string 155 - CommentAt string 156 - 157 - // content 158 - Body string 159 - 160 - // meta 161 - Created time.Time 162 - } 163 - 164 - func (p *Pull) LatestPatch() string { 165 - latestSubmission := p.Submissions[p.LastRoundNumber()] 166 - return latestSubmission.Patch 167 - } 168 - 169 - func (p *Pull) LatestSha() string { 170 - latestSubmission := p.Submissions[p.LastRoundNumber()] 171 - return latestSubmission.SourceRev 172 - } 173 - 174 - func (p *Pull) PullAt() syntax.ATURI { 175 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 176 - } 177 - 178 - func (p *Pull) LastRoundNumber() int { 179 - return len(p.Submissions) - 1 180 - } 181 - 182 - func (p *Pull) IsPatchBased() bool { 183 - return p.PullSource == nil 184 - } 185 - 186 - func (p *Pull) IsBranchBased() bool { 187 - if p.PullSource != nil { 188 - if p.PullSource.RepoAt != nil { 189 - return p.PullSource.RepoAt == &p.RepoAt 190 - } else { 191 - // no repo specified 192 - return true 193 - } 194 - } 195 - return false 196 - } 197 - 198 - func (p *Pull) IsForkBased() bool { 199 - if p.PullSource != nil { 200 - if p.PullSource.RepoAt != nil { 201 - // make sure repos are different 202 - return p.PullSource.RepoAt != &p.RepoAt 203 - } 204 - } 205 - return false 206 - } 207 - 208 - func (p *Pull) IsStacked() bool { 209 - return p.StackId != "" 210 - } 211 - 212 - func (s PullSubmission) IsFormatPatch() bool { 213 - return patchutil.IsFormatPatch(s.Patch) 214 - } 215 - 216 - func (s PullSubmission) AsFormatPatch() []types.FormatPatch { 217 - patches, err := patchutil.ExtractPatches(s.Patch) 218 - if err != nil { 219 - log.Println("error extracting patches from submission:", err) 220 - return []types.FormatPatch{} 221 - } 222 - 223 - return patches 224 - } 225 - 226 - func NewPull(tx *sql.Tx, pull *Pull) error { 18 + func NewPull(tx *sql.Tx, pull *models.Pull) error { 227 19 _, err := tx.Exec(` 228 20 insert or ignore into repo_pull_seqs (repo_at, next_pull_id) 229 21 values (?, 1) ··· 244 36 } 245 37 246 38 pull.PullId = nextId 247 - pull.State = PullOpen 39 + pull.State = models.PullOpen 248 40 249 41 var sourceBranch, sourceRepoAt *string 250 42 if pull.PullSource != nil { ··· 266 58 parentChangeId = &pull.ParentChangeId 267 59 } 268 60 269 - _, err = tx.Exec( 61 + result, err := tx.Exec( 270 62 ` 271 63 insert into pulls ( 272 64 repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id ··· 290 82 return err 291 83 } 292 84 85 + // Set the database primary key ID 86 + id, err := result.LastInsertId() 87 + if err != nil { 88 + return err 89 + } 90 + pull.ID = int(id) 91 + 293 92 _, err = tx.Exec(` 294 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 295 - values (?, ?, ?, ?, ?) 296 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 93 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 94 + values (?, ?, ?, ?) 95 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 297 96 return err 298 97 } 299 98 ··· 311 110 return pullId - 1, err 312 111 } 313 112 314 - func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 315 - pulls := make(map[int]*Pull) 113 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 114 + pulls := make(map[syntax.ATURI]*models.Pull) 316 115 317 116 var conditions []string 318 117 var args []any ··· 332 131 333 132 query := fmt.Sprintf(` 334 133 select 134 + id, 335 135 owner_did, 336 136 repo_at, 337 137 pull_id, ··· 361 161 defer rows.Close() 362 162 363 163 for rows.Next() { 364 - var pull Pull 164 + var pull models.Pull 365 165 var createdAt string 366 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 367 167 err := rows.Scan( 168 + &pull.ID, 368 169 &pull.OwnerDid, 369 170 &pull.RepoAt, 370 171 &pull.PullId, ··· 391 192 pull.Created = createdTime 392 193 393 194 if sourceBranch.Valid { 394 - pull.PullSource = &PullSource{ 195 + pull.PullSource = &models.PullSource{ 395 196 Branch: sourceBranch.String, 396 197 } 397 198 if sourceRepoAt.Valid { ··· 413 214 pull.ParentChangeId = parentChangeId.String 414 215 } 415 216 416 - pulls[pull.PullId] = &pull 217 + pulls[pull.PullAt()] = &pull 417 218 } 418 219 419 - // get latest round no. for each pull 420 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 421 - submissionsQuery := fmt.Sprintf(` 422 - select 423 - id, pull_id, round_number, patch, created, source_rev 424 - from 425 - pull_submissions 426 - where 427 - repo_at in (%s) and pull_id in (%s) 428 - `, inClause, inClause) 429 - 430 - args = make([]any, len(pulls)*2) 431 - idx := 0 432 - for _, p := range pulls { 433 - args[idx] = p.RepoAt 434 - idx += 1 435 - } 220 + var pullAts []syntax.ATURI 436 221 for _, p := range pulls { 437 - args[idx] = p.PullId 438 - idx += 1 222 + pullAts = append(pullAts, p.PullAt()) 439 223 } 440 - submissionsRows, err := e.Query(submissionsQuery, args...) 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 441 225 if err != nil { 442 - return nil, err 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 443 227 } 444 - defer submissionsRows.Close() 445 228 446 - for submissionsRows.Next() { 447 - var s PullSubmission 448 - var sourceRev sql.NullString 449 - var createdAt string 450 - err := submissionsRows.Scan( 451 - &s.ID, 452 - &s.PullId, 453 - &s.RoundNumber, 454 - &s.Patch, 455 - &createdAt, 456 - &sourceRev, 457 - ) 458 - if err != nil { 459 - return nil, err 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 460 232 } 461 - 462 - createdTime, err := time.Parse(time.RFC3339, createdAt) 463 - if err != nil { 464 - return nil, err 465 - } 466 - s.Created = createdTime 233 + } 467 234 468 - if sourceRev.Valid { 469 - s.SourceRev = sourceRev.String 235 + // collect allLabels for each issue 236 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 237 + if err != nil { 238 + return nil, fmt.Errorf("failed to query labels: %w", err) 239 + } 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 470 243 } 244 + } 471 245 472 - if p, ok := pulls[s.PullId]; ok { 473 - p.Submissions = make([]*PullSubmission, s.RoundNumber+1) 474 - p.Submissions[s.RoundNumber] = &s 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 248 + for _, p := range pulls { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 475 251 } 476 252 } 477 - if err := rows.Err(); err != nil { 478 - return nil, err 253 + sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 + return nil, fmt.Errorf("failed to get source repos: %w", err) 479 256 } 480 - 481 - // get comment count on latest submission on each pull 482 - inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 483 - commentsQuery := fmt.Sprintf(` 484 - select 485 - count(id), pull_id 486 - from 487 - pull_comments 488 - where 489 - submission_id in (%s) 490 - group by 491 - submission_id 492 - `, inClause) 493 - 494 - args = []any{} 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 260 + } 495 261 for _, p := range pulls { 496 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 497 - } 498 - commentsRows, err := e.Query(commentsQuery, args...) 499 - if err != nil { 500 - return nil, err 501 - } 502 - defer commentsRows.Close() 503 - 504 - for commentsRows.Next() { 505 - var commentCount, pullId int 506 - err := commentsRows.Scan( 507 - &commentCount, 508 - &pullId, 509 - ) 510 - if err != nil { 511 - return nil, err 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 512 266 } 513 - if p, ok := pulls[pullId]; ok { 514 - p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount) 515 - } 516 - } 517 - if err := rows.Err(); err != nil { 518 - return nil, err 519 267 } 520 268 521 - orderedByPullId := []*Pull{} 269 + orderedByPullId := []*models.Pull{} 522 270 for _, p := range pulls { 523 271 orderedByPullId = append(orderedByPullId, p) 524 272 } ··· 529 277 return orderedByPullId, nil 530 278 } 531 279 532 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 280 + func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) { 533 281 return GetPullsWithLimit(e, 0, filters...) 534 282 } 535 283 536 - func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { 537 - query := ` 538 - select 539 - owner_did, 540 - pull_id, 541 - created, 542 - title, 543 - state, 544 - target_branch, 545 - repo_at, 546 - body, 547 - rkey, 548 - source_branch, 549 - source_repo_at, 550 - stack_id, 551 - change_id, 552 - parent_change_id 553 - from 554 - pulls 555 - where 556 - repo_at = ? and pull_id = ? 557 - ` 558 - row := e.QueryRow(query, repoAt, pullId) 559 - 560 - var pull Pull 561 - var createdAt string 562 - var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 563 - err := row.Scan( 564 - &pull.OwnerDid, 565 - &pull.PullId, 566 - &createdAt, 567 - &pull.Title, 568 - &pull.State, 569 - &pull.TargetBranch, 570 - &pull.RepoAt, 571 - &pull.Body, 572 - &pull.Rkey, 573 - &sourceBranch, 574 - &sourceRepoAt, 575 - &stackId, 576 - &changeId, 577 - &parentChangeId, 578 - ) 284 + func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 579 286 if err != nil { 580 287 return nil, err 581 288 } 582 - 583 - createdTime, err := time.Parse(time.RFC3339, createdAt) 584 - if err != nil { 585 - return nil, err 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 586 291 } 587 - pull.Created = createdTime 588 292 589 - // populate source 590 - if sourceBranch.Valid { 591 - pull.PullSource = &PullSource{ 592 - Branch: sourceBranch.String, 593 - } 594 - if sourceRepoAt.Valid { 595 - sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 596 - if err != nil { 597 - return nil, err 598 - } 599 - pull.PullSource.RepoAt = &sourceRepoAtParsed 600 - } 601 - } 293 + return pulls[0], nil 294 + } 602 295 603 - if stackId.Valid { 604 - pull.StackId = stackId.String 605 - } 606 - if changeId.Valid { 607 - pull.ChangeId = changeId.String 296 + // mapping from pull -> pull submissions 297 + func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 + var conditions []string 299 + var args []any 300 + for _, filter := range filters { 301 + conditions = append(conditions, filter.Condition()) 302 + args = append(args, filter.Arg()...) 608 303 } 609 - if parentChangeId.Valid { 610 - pull.ParentChangeId = parentChangeId.String 304 + 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 611 308 } 612 309 613 - submissionsQuery := ` 310 + query := fmt.Sprintf(` 614 311 select 615 - id, pull_id, repo_at, round_number, patch, created, source_rev 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + created, 317 + source_rev 616 318 from 617 319 pull_submissions 618 - where 619 - repo_at = ? and pull_id = ? 620 - ` 621 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 320 + %s 321 + order by 322 + round_number asc 323 + `, whereClause) 324 + 325 + rows, err := e.Query(query, args...) 622 326 if err != nil { 623 327 return nil, err 624 328 } 625 - defer submissionsRows.Close() 329 + defer rows.Close() 626 330 627 - submissionsMap := make(map[int]*PullSubmission) 331 + submissionMap := make(map[int]*models.PullSubmission) 628 332 629 - for submissionsRows.Next() { 630 - var submission PullSubmission 631 - var submissionCreatedStr string 632 - var submissionSourceRev sql.NullString 633 - err := submissionsRows.Scan( 333 + for rows.Next() { 334 + var submission models.PullSubmission 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 634 338 &submission.ID, 635 - &submission.PullId, 636 - &submission.RepoAt, 339 + &submission.PullAt, 637 340 &submission.RoundNumber, 638 341 &submission.Patch, 639 - &submissionCreatedStr, 640 - &submissionSourceRev, 342 + &createdAt, 343 + &sourceRev, 641 344 ) 642 345 if err != nil { 643 346 return nil, err 644 347 } 645 348 646 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 647 350 if err != nil { 648 351 return nil, err 649 352 } 650 - submission.Created = submissionCreatedTime 353 + submission.Created = createdTime 651 354 652 - if submissionSourceRev.Valid { 653 - submission.SourceRev = submissionSourceRev.String 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 654 357 } 655 358 656 - submissionsMap[submission.ID] = &submission 359 + submissionMap[submission.ID] = &submission 657 360 } 658 - if err = submissionsRows.Close(); err != nil { 361 + 362 + if err := rows.Err(); err != nil { 363 + return nil, err 364 + } 365 + 366 + // Get comments for all submissions using GetPullComments 367 + submissionIds := slices.Collect(maps.Keys(submissionMap)) 368 + comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 369 + if err != nil { 659 370 return nil, err 660 371 } 661 - if len(submissionsMap) == 0 { 662 - return &pull, nil 372 + for _, comment := range comments { 373 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 374 + submission.Comments = append(submission.Comments, comment) 375 + } 376 + } 377 + 378 + // group the submissions by pull_at 379 + m := make(map[syntax.ATURI][]*models.PullSubmission) 380 + for _, s := range submissionMap { 381 + m[s.PullAt] = append(m[s.PullAt], s) 382 + } 383 + 384 + // sort each one by round number 385 + for _, s := range m { 386 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 387 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 388 + }) 663 389 } 664 390 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 665 396 var args []any 666 - for k := range submissionsMap { 667 - args = append(args, k) 397 + for _, filter := range filters { 398 + conditions = append(conditions, filter.Condition()) 399 + args = append(args, filter.Arg()...) 668 400 } 669 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 670 - commentsQuery := fmt.Sprintf(` 401 + 402 + whereClause := "" 403 + if conditions != nil { 404 + whereClause = " where " + strings.Join(conditions, " and ") 405 + } 406 + 407 + query := fmt.Sprintf(` 671 408 select 672 409 id, 673 410 pull_id, ··· 679 416 created 680 417 from 681 418 pull_comments 682 - where 683 - submission_id IN (%s) 419 + %s 684 420 order by 685 421 created asc 686 - `, inClause) 687 - commentsRows, err := e.Query(commentsQuery, args...) 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 688 425 if err != nil { 689 426 return nil, err 690 427 } 691 - defer commentsRows.Close() 428 + defer rows.Close() 692 429 693 - for commentsRows.Next() { 694 - var comment PullComment 695 - var commentCreatedStr string 696 - err := commentsRows.Scan( 430 + var comments []models.PullComment 431 + for rows.Next() { 432 + var comment models.PullComment 433 + var createdAt string 434 + err := rows.Scan( 697 435 &comment.ID, 698 436 &comment.PullId, 699 437 &comment.SubmissionId, ··· 701 439 &comment.OwnerDid, 702 440 &comment.CommentAt, 703 441 &comment.Body, 704 - &commentCreatedStr, 442 + &createdAt, 705 443 ) 706 444 if err != nil { 707 445 return nil, err 708 446 } 709 447 710 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 711 - if err != nil { 712 - return nil, err 713 - } 714 - comment.Created = commentCreatedTime 715 - 716 - // Add the comment to its submission 717 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 718 - submission.Comments = append(submission.Comments, comment) 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 719 450 } 720 451 721 - } 722 - if err = commentsRows.Err(); err != nil { 723 - return nil, err 724 - } 725 - 726 - var pullSourceRepo *Repo 727 - if pull.PullSource != nil { 728 - if pull.PullSource.RepoAt != nil { 729 - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 730 - if err != nil { 731 - log.Printf("failed to get repo by at uri: %v", err) 732 - } else { 733 - pull.PullSource.Repo = pullSourceRepo 734 - } 735 - } 452 + comments = append(comments, comment) 736 453 } 737 454 738 - pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 739 - for _, submission := range submissionsMap { 740 - pull.Submissions[submission.RoundNumber] = submission 455 + if err := rows.Err(); err != nil { 456 + return nil, err 741 457 } 742 458 743 - return &pull, nil 459 + return comments, nil 744 460 } 745 461 746 462 // timeframe here is directly passed into the sql query filter, and any 747 463 // timeframe in the past should be negative; e.g.: "-3 months" 748 - func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) { 749 - var pulls []Pull 464 + func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { 465 + var pulls []models.Pull 750 466 751 467 rows, err := e.Query(` 752 468 select ··· 775 491 defer rows.Close() 776 492 777 493 for rows.Next() { 778 - var pull Pull 779 - var repo Repo 494 + var pull models.Pull 495 + var repo models.Repo 780 496 var pullCreatedAt, repoCreatedAt string 781 497 err := rows.Scan( 782 498 &pull.OwnerDid, ··· 819 535 return pulls, nil 820 536 } 821 537 822 - func NewPullComment(e Execer, comment *PullComment) (int64, error) { 538 + func NewPullComment(e Execer, comment *models.PullComment) (int64, error) { 823 539 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 824 540 res, err := e.Exec( 825 541 query, ··· 842 558 return i, nil 843 559 } 844 560 845 - func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error { 561 + func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error { 846 562 _, err := e.Exec( 847 563 `update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`, 848 564 pullState, 849 565 repoAt, 850 566 pullId, 851 - PullDeleted, // only update state of non-deleted pulls 852 - PullMerged, // only update state of non-merged pulls 567 + models.PullDeleted, // only update state of non-deleted pulls 568 + models.PullMerged, // only update state of non-merged pulls 853 569 ) 854 570 return err 855 571 } 856 572 857 573 func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error { 858 - err := SetPullState(e, repoAt, pullId, PullClosed) 574 + err := SetPullState(e, repoAt, pullId, models.PullClosed) 859 575 return err 860 576 } 861 577 862 578 func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error { 863 - err := SetPullState(e, repoAt, pullId, PullOpen) 579 + err := SetPullState(e, repoAt, pullId, models.PullOpen) 864 580 return err 865 581 } 866 582 867 583 func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error { 868 - err := SetPullState(e, repoAt, pullId, PullMerged) 584 + err := SetPullState(e, repoAt, pullId, models.PullMerged) 869 585 return err 870 586 } 871 587 872 588 func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error { 873 - err := SetPullState(e, repoAt, pullId, PullDeleted) 589 + err := SetPullState(e, repoAt, pullId, models.PullDeleted) 874 590 return err 875 591 } 876 592 877 - func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error { 593 + func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 878 594 newRoundNumber := len(pull.Submissions) 879 595 _, err := e.Exec(` 880 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 881 - values (?, ?, ?, ?, ?) 882 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 596 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 + values (?, ?, ?, ?) 598 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 883 599 884 600 return err 885 601 } ··· 931 647 return err 932 648 } 933 649 934 - type PullCount struct { 935 - Open int 936 - Merged int 937 - Closed int 938 - Deleted int 939 - } 940 - 941 - func GetPullCount(e Execer, repoAt syntax.ATURI) (PullCount, error) { 650 + func GetPullCount(e Execer, repoAt syntax.ATURI) (models.PullCount, error) { 942 651 row := e.QueryRow(` 943 652 select 944 653 count(case when state = ? then 1 end) as open_count, ··· 947 656 count(case when state = ? then 1 end) as deleted_count 948 657 from pulls 949 658 where repo_at = ?`, 950 - PullOpen, 951 - PullMerged, 952 - PullClosed, 953 - PullDeleted, 659 + models.PullOpen, 660 + models.PullMerged, 661 + models.PullClosed, 662 + models.PullDeleted, 954 663 repoAt, 955 664 ) 956 665 957 - var count PullCount 666 + var count models.PullCount 958 667 if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil { 959 - return PullCount{0, 0, 0, 0}, err 668 + return models.PullCount{Open: 0, Merged: 0, Closed: 0, Deleted: 0}, err 960 669 } 961 670 962 671 return count, nil 963 672 } 964 - 965 - type Stack []*Pull 966 673 967 674 // change-id parent-change-id 968 675 // ··· 972 679 // 1 x <------' nil (BOT) 973 680 // 974 681 // `w` is parent of none, so it is the top of the stack 975 - func GetStack(e Execer, stackId string) (Stack, error) { 682 + func GetStack(e Execer, stackId string) (models.Stack, error) { 976 683 unorderedPulls, err := GetPulls( 977 684 e, 978 685 FilterEq("stack_id", stackId), 979 - FilterNotEq("state", PullDeleted), 686 + FilterNotEq("state", models.PullDeleted), 980 687 ) 981 688 if err != nil { 982 689 return nil, err 983 690 } 984 691 // map of parent-change-id to pull 985 - changeIdMap := make(map[string]*Pull, len(unorderedPulls)) 986 - parentMap := make(map[string]*Pull, len(unorderedPulls)) 692 + changeIdMap := make(map[string]*models.Pull, len(unorderedPulls)) 693 + parentMap := make(map[string]*models.Pull, len(unorderedPulls)) 987 694 for _, p := range unorderedPulls { 988 695 changeIdMap[p.ChangeId] = p 989 696 if p.ParentChangeId != "" { ··· 992 699 } 993 700 994 701 // the top of the stack is the pull that is not a parent of any pull 995 - var topPull *Pull 702 + var topPull *models.Pull 996 703 for _, maybeTop := range unorderedPulls { 997 704 if _, ok := parentMap[maybeTop.ChangeId]; !ok { 998 705 topPull = maybeTop ··· 1000 707 } 1001 708 } 1002 709 1003 - pulls := []*Pull{} 710 + pulls := []*models.Pull{} 1004 711 for { 1005 712 pulls = append(pulls, topPull) 1006 713 if topPull.ParentChangeId != "" { ··· 1017 724 return pulls, nil 1018 725 } 1019 726 1020 - func GetAbandonedPulls(e Execer, stackId string) ([]*Pull, error) { 727 + func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { 1021 728 pulls, err := GetPulls( 1022 729 e, 1023 730 FilterEq("stack_id", stackId), 1024 - FilterEq("state", PullDeleted), 731 + FilterEq("state", models.PullDeleted), 1025 732 ) 1026 733 if err != nil { 1027 734 return nil, err ··· 1029 736 1030 737 return pulls, nil 1031 738 } 1032 - 1033 - // position of this pull in the stack 1034 - func (stack Stack) Position(pull *Pull) int { 1035 - return slices.IndexFunc(stack, func(p *Pull) bool { 1036 - return p.ChangeId == pull.ChangeId 1037 - }) 1038 - } 1039 - 1040 - // all pulls below this pull (including self) in this stack 1041 - // 1042 - // nil if this pull does not belong to this stack 1043 - func (stack Stack) Below(pull *Pull) Stack { 1044 - position := stack.Position(pull) 1045 - 1046 - if position < 0 { 1047 - return nil 1048 - } 1049 - 1050 - return stack[position:] 1051 - } 1052 - 1053 - // all pulls below this pull (excluding self) in this stack 1054 - func (stack Stack) StrictlyBelow(pull *Pull) Stack { 1055 - below := stack.Below(pull) 1056 - 1057 - if len(below) > 0 { 1058 - return below[1:] 1059 - } 1060 - 1061 - return nil 1062 - } 1063 - 1064 - // all pulls above this pull (including self) in this stack 1065 - func (stack Stack) Above(pull *Pull) Stack { 1066 - position := stack.Position(pull) 1067 - 1068 - if position < 0 { 1069 - return nil 1070 - } 1071 - 1072 - return stack[:position+1] 1073 - } 1074 - 1075 - // all pulls below this pull (excluding self) in this stack 1076 - func (stack Stack) StrictlyAbove(pull *Pull) Stack { 1077 - above := stack.Above(pull) 1078 - 1079 - if len(above) > 0 { 1080 - return above[:len(above)-1] 1081 - } 1082 - 1083 - return nil 1084 - } 1085 - 1086 - // the combined format-patches of all the newest submissions in this stack 1087 - func (stack Stack) CombinedPatch() string { 1088 - // go in reverse order because the bottom of the stack is the last element in the slice 1089 - var combined strings.Builder 1090 - for idx := range stack { 1091 - pull := stack[len(stack)-1-idx] 1092 - combined.WriteString(pull.LatestPatch()) 1093 - combined.WriteString("\n") 1094 - } 1095 - return combined.String() 1096 - } 1097 - 1098 - // filter out PRs that are "active" 1099 - // 1100 - // PRs that are still open are active 1101 - func (stack Stack) Mergeable() Stack { 1102 - var mergeable Stack 1103 - 1104 - for _, p := range stack { 1105 - // stop at the first merged PR 1106 - if p.State == PullMerged || p.State == PullClosed { 1107 - break 1108 - } 1109 - 1110 - // skip over deleted PRs 1111 - if p.State != PullDeleted { 1112 - mergeable = append(mergeable, p) 1113 - } 1114 - } 1115 - 1116 - return mergeable 1117 - }
+9 -18
appview/db/punchcard.go
··· 5 5 "fmt" 6 6 "strings" 7 7 "time" 8 - ) 9 8 10 - type Punch struct { 11 - Did string 12 - Date time.Time 13 - Count int 14 - } 9 + "tangled.org/core/appview/models" 10 + ) 15 11 16 12 // this adds to the existing count 17 - func AddPunch(e Execer, punch Punch) error { 13 + func AddPunch(e Execer, punch models.Punch) error { 18 14 _, err := e.Exec(` 19 15 insert into punchcard (did, date, count) 20 16 values (?, ?, ?) ··· 24 20 return err 25 21 } 26 22 27 - type Punchcard struct { 28 - Total int 29 - Punches []Punch 30 - } 31 - 32 - func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) { 33 - punchcard := Punchcard{} 23 + func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) { 24 + punchcard := &models.Punchcard{} 34 25 now := time.Now() 35 26 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 27 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) 37 28 for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) { 38 - punchcard.Punches = append(punchcard.Punches, Punch{ 29 + punchcard.Punches = append(punchcard.Punches, models.Punch{ 39 30 Date: d, 40 31 Count: 0, 41 32 }) ··· 63 54 64 55 rows, err := e.Query(query, args...) 65 56 if err != nil { 66 - return punchcard, err 57 + return nil, err 67 58 } 68 59 defer rows.Close() 69 60 70 61 for rows.Next() { 71 - var punch Punch 62 + var punch models.Punch 72 63 var date string 73 64 var count sql.NullInt64 74 65 if err := rows.Scan(&date, &count); err != nil { 75 - return punchcard, err 66 + return nil, err 76 67 } 77 68 78 69 punch.Date, err = time.Parse(time.DateOnly, date)
+45 -67
appview/db/reaction.go
··· 5 5 "time" 6 6 7 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 - ) 9 - 10 - type ReactionKind string 11 - 12 - const ( 13 - Like ReactionKind = "👍" 14 - Unlike ReactionKind = "👎" 15 - Laugh ReactionKind = "😆" 16 - Celebration ReactionKind = "🎉" 17 - Confused ReactionKind = "🫤" 18 - Heart ReactionKind = "❤️" 19 - Rocket ReactionKind = "🚀" 20 - Eyes ReactionKind = "👀" 8 + "tangled.org/core/appview/models" 21 9 ) 22 10 23 - func (rk ReactionKind) String() string { 24 - return string(rk) 25 - } 26 - 27 - var OrderedReactionKinds = []ReactionKind{ 28 - Like, 29 - Unlike, 30 - Laugh, 31 - Celebration, 32 - Confused, 33 - Heart, 34 - Rocket, 35 - Eyes, 36 - } 37 - 38 - func ParseReactionKind(raw string) (ReactionKind, bool) { 39 - k, ok := (map[string]ReactionKind{ 40 - "👍": Like, 41 - "👎": Unlike, 42 - "😆": Laugh, 43 - "🎉": Celebration, 44 - "🫤": Confused, 45 - "❤️": Heart, 46 - "🚀": Rocket, 47 - "👀": Eyes, 48 - })[raw] 49 - return k, ok 50 - } 51 - 52 - type Reaction struct { 53 - ReactedByDid string 54 - ThreadAt syntax.ATURI 55 - Created time.Time 56 - Rkey string 57 - Kind ReactionKind 58 - } 59 - 60 - func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error { 11 + func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error { 61 12 query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 62 13 _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 63 14 return err 64 15 } 65 16 66 17 // Get a reaction record 67 - func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) { 18 + func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) { 68 19 query := ` 69 20 select reacted_by_did, thread_at, created, rkey 70 21 from reactions 71 22 where reacted_by_did = ? and thread_at = ? and kind = ?` 72 23 row := e.QueryRow(query, reactedByDid, threadAt, kind) 73 24 74 - var reaction Reaction 25 + var reaction models.Reaction 75 26 var created string 76 27 err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 77 28 if err != nil { ··· 90 41 } 91 42 92 43 // Remove a reaction 93 - func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error { 44 + func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error { 94 45 _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 95 46 return err 96 47 } ··· 101 52 return err 102 53 } 103 54 104 - func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) { 55 + func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) { 105 56 count := 0 106 57 err := e.QueryRow( 107 58 `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) ··· 111 62 return count, nil 112 63 } 113 64 114 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) { 115 - countMap := map[ReactionKind]int{} 116 - for _, kind := range OrderedReactionKinds { 117 - count, err := GetReactionCount(e, threadAt, kind) 118 - if err != nil { 119 - return map[ReactionKind]int{}, nil 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 81 + for _, kind := range models.OrderedReactionKinds { 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 120 91 } 121 - countMap[kind] = count 92 + 93 + data := reactionMap[kind] 94 + data.Count = total 95 + if userLimit > 0 && rn <= userLimit { 96 + data.Users = append(data.Users, did) 97 + } 98 + reactionMap[kind] = data 122 99 } 123 - return countMap, nil 100 + 101 + return reactionMap, rows.Err() 124 102 } 125 103 126 - func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool { 104 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { 127 105 if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 128 106 return false 129 107 } else { ··· 131 109 } 132 110 } 133 111 134 - func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool { 135 - statusMap := map[ReactionKind]bool{} 136 - for _, kind := range OrderedReactionKinds { 112 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool { 113 + statusMap := map[models.ReactionKind]bool{} 114 + for _, kind := range models.OrderedReactionKinds { 137 115 count := GetReactionStatus(e, userDid, threadAt, kind) 138 116 statusMap[kind] = count 139 117 }
+10 -49
appview/db/registration.go
··· 5 5 "fmt" 6 6 "strings" 7 7 "time" 8 - ) 9 8 10 - // Registration represents a knot registration. Knot would've been a better 11 - // name but we're stuck with this for historical reasons. 12 - type Registration struct { 13 - Id int64 14 - Domain string 15 - ByDid string 16 - Created *time.Time 17 - Registered *time.Time 18 - ReadOnly bool 19 - } 20 - 21 - func (r *Registration) Status() Status { 22 - if r.ReadOnly { 23 - return ReadOnly 24 - } else if r.Registered != nil { 25 - return Registered 26 - } else { 27 - return Pending 28 - } 29 - } 30 - 31 - func (r *Registration) IsRegistered() bool { 32 - return r.Status() == Registered 33 - } 34 - 35 - func (r *Registration) IsReadOnly() bool { 36 - return r.Status() == ReadOnly 37 - } 38 - 39 - func (r *Registration) IsPending() bool { 40 - return r.Status() == Pending 41 - } 42 - 43 - type Status uint32 44 - 45 - const ( 46 - Registered Status = iota 47 - Pending 48 - ReadOnly 9 + "tangled.org/core/appview/models" 49 10 ) 50 11 51 - func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 52 - var registrations []Registration 12 + func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) { 13 + var registrations []models.Registration 53 14 54 15 var conditions []string 55 16 var args []any ··· 64 25 } 65 26 66 27 query := fmt.Sprintf(` 67 - select id, domain, did, created, registered, read_only 28 + select id, domain, did, created, registered, needs_upgrade 68 29 from registrations 69 30 %s 70 31 order by created ··· 80 41 for rows.Next() { 81 42 var createdAt string 82 43 var registeredAt sql.Null[string] 83 - var readOnly int 84 - var reg Registration 44 + var needsUpgrade int 45 + var reg models.Registration 85 46 86 - err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 47 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 48 if err != nil { 88 49 return nil, err 89 50 } ··· 98 59 } 99 60 } 100 61 101 - if readOnly != 0 { 102 - reg.ReadOnly = true 62 + if needsUpgrade != 0 { 63 + reg.NeedsUpgrade = true 103 64 } 104 65 105 66 registrations = append(registrations, reg) ··· 116 77 args = append(args, filter.Arg()...) 117 78 } 118 79 119 - query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 80 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), needs_upgrade = 0" 120 81 if len(conditions) > 0 { 121 82 query += " where " + strings.Join(conditions, " and ") 122 83 }
+160 -182
appview/db/repos.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "errors" 5 6 "fmt" 6 7 "log" 7 8 "slices" ··· 10 11 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 12 13 securejoin "github.com/cyphar/filepath-securejoin" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/models" 14 16 ) 15 17 16 18 type Repo struct { 19 + Id int64 17 20 Did string 18 21 Name string 19 22 Knot string ··· 23 26 Spindle string 24 27 25 28 // optionally, populate this when querying for reverse mappings 26 - RepoStats *RepoStats 29 + RepoStats *models.RepoStats 27 30 28 31 // optional 29 32 Source string ··· 38 41 return p 39 42 } 40 43 41 - func GetAllRepos(e Execer, limit int) ([]Repo, error) { 42 - var repos []Repo 43 - 44 - rows, err := e.Query( 45 - `select did, name, knot, rkey, description, created, source 46 - from repos 47 - order by created desc 48 - limit ? 49 - `, 50 - limit, 51 - ) 52 - if err != nil { 53 - return nil, err 54 - } 55 - defer rows.Close() 56 - 57 - for rows.Next() { 58 - var repo Repo 59 - err := scanRepo( 60 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 61 - ) 62 - if err != nil { 63 - return nil, err 64 - } 65 - repos = append(repos, repo) 66 - } 67 - 68 - if err := rows.Err(); err != nil { 69 - return nil, err 70 - } 71 - 72 - return repos, nil 73 - } 74 - 75 - func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { 76 - repoMap := make(map[syntax.ATURI]*Repo) 44 + func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 45 + repoMap := make(map[syntax.ATURI]*models.Repo) 77 46 78 47 var conditions []string 79 48 var args []any ··· 94 63 95 64 repoQuery := fmt.Sprintf( 96 65 `select 66 + id, 97 67 did, 98 68 name, 99 69 knot, ··· 117 87 } 118 88 119 89 for rows.Next() { 120 - var repo Repo 90 + var repo models.Repo 121 91 var createdAt string 122 92 var description, source, spindle sql.NullString 123 93 124 94 err := rows.Scan( 95 + &repo.Id, 125 96 &repo.Did, 126 97 &repo.Name, 127 98 &repo.Knot, ··· 148 119 repo.Spindle = spindle.String 149 120 } 150 121 151 - repo.RepoStats = &RepoStats{} 122 + repo.RepoStats = &models.RepoStats{} 152 123 repoMap[repo.RepoAt()] = &repo 153 124 } 154 125 ··· 165 136 i++ 166 137 } 167 138 139 + // Get labels for all repos 140 + labelsQuery := fmt.Sprintf( 141 + `select repo_at, label_at from repo_labels where repo_at in (%s)`, 142 + inClause, 143 + ) 144 + rows, err = e.Query(labelsQuery, args...) 145 + if err != nil { 146 + return nil, fmt.Errorf("failed to execute labels query: %w ", err) 147 + } 148 + for rows.Next() { 149 + var repoat, labelat string 150 + if err := rows.Scan(&repoat, &labelat); err != nil { 151 + log.Println("err", "err", err) 152 + continue 153 + } 154 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 155 + r.Labels = append(r.Labels, labelat) 156 + } 157 + } 158 + if err = rows.Err(); err != nil { 159 + return nil, fmt.Errorf("failed to execute labels query: %w ", err) 160 + } 161 + 168 162 languageQuery := fmt.Sprintf( 169 163 ` 170 - select 171 - repo_at, language 172 - from 173 - repo_languages r1 174 - where 175 - repo_at IN (%s) 164 + select repo_at, language 165 + from ( 166 + select 167 + repo_at, 168 + language, 169 + row_number() over ( 170 + partition by repo_at 171 + order by bytes desc 172 + ) as rn 173 + from repo_languages 174 + where repo_at in (%s) 176 175 and is_default_ref = 1 177 - and id = ( 178 - select id 179 - from repo_languages r2 180 - where r2.repo_at = r1.repo_at 181 - and r2.is_default_ref = 1 182 - order by bytes desc 183 - limit 1 184 - ); 176 + ) 177 + where rn = 1 185 178 `, 186 179 inClause, 187 180 ) ··· 273 266 inClause, 274 267 ) 275 268 args = append([]any{ 276 - PullOpen, 277 - PullMerged, 278 - PullClosed, 279 - PullDeleted, 269 + models.PullOpen, 270 + models.PullMerged, 271 + models.PullClosed, 272 + models.PullDeleted, 280 273 }, args...) 281 274 rows, err = e.Query( 282 275 pullCountQuery, ··· 303 296 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 304 297 } 305 298 306 - var repos []Repo 299 + var repos []models.Repo 307 300 for _, r := range repoMap { 308 301 repos = append(repos, *r) 309 302 } 310 303 311 - slices.SortFunc(repos, func(a, b Repo) int { 304 + slices.SortFunc(repos, func(a, b models.Repo) int { 312 305 if a.Created.After(b.Created) { 313 - return 1 306 + return -1 314 307 } 315 - return -1 308 + return 1 316 309 }) 317 310 318 311 return repos, nil 319 312 } 320 313 321 - func GetAllReposByDid(e Execer, did string) ([]Repo, error) { 322 - var repos []Repo 323 - 324 - rows, err := e.Query( 325 - `select 326 - r.did, 327 - r.name, 328 - r.knot, 329 - r.rkey, 330 - r.description, 331 - r.created, 332 - count(s.id) as star_count, 333 - r.source 334 - from 335 - repos r 336 - left join 337 - stars s on r.at_uri = s.repo_at 338 - where 339 - r.did = ? 340 - group by 341 - r.at_uri 342 - order by r.created desc`, 343 - did) 314 + // helper to get exactly one repo 315 + func GetRepo(e Execer, filters ...filter) (*models.Repo, error) { 316 + repos, err := GetRepos(e, 0, filters...) 344 317 if err != nil { 345 318 return nil, err 346 319 } 347 - defer rows.Close() 348 - 349 - for rows.Next() { 350 - var repo Repo 351 - var repoStats RepoStats 352 - var createdAt string 353 - var nullableDescription sql.NullString 354 - var nullableSource sql.NullString 355 - 356 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource) 357 - if err != nil { 358 - return nil, err 359 - } 360 - 361 - if nullableDescription.Valid { 362 - repo.Description = nullableDescription.String 363 - } 364 - 365 - if nullableSource.Valid { 366 - repo.Source = nullableSource.String 367 - } 368 - 369 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 370 - if err != nil { 371 - repo.Created = time.Now() 372 - } else { 373 - repo.Created = createdAtTime 374 - } 375 320 376 - repo.RepoStats = &repoStats 377 - 378 - repos = append(repos, repo) 321 + if repos == nil { 322 + return nil, sql.ErrNoRows 379 323 } 380 324 381 - if err := rows.Err(); err != nil { 382 - return nil, err 325 + if len(repos) != 1 { 326 + return nil, fmt.Errorf("too many rows returned") 383 327 } 384 328 385 - return repos, nil 329 + return &repos[0], nil 386 330 } 387 331 388 - func GetRepo(e Execer, did, name string) (*Repo, error) { 389 - var repo Repo 390 - var description, spindle sql.NullString 391 - 392 - row := e.QueryRow(` 393 - select did, name, knot, created, description, spindle, rkey 394 - from repos 395 - where did = ? and name = ? 396 - `, 397 - did, 398 - name, 399 - ) 400 - 401 - var createdAt string 402 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil { 403 - return nil, err 332 + func CountRepos(e Execer, filters ...filter) (int64, error) { 333 + var conditions []string 334 + var args []any 335 + for _, filter := range filters { 336 + conditions = append(conditions, filter.Condition()) 337 + args = append(args, filter.Arg()...) 404 338 } 405 - createdAtTime, _ := time.Parse(time.RFC3339, createdAt) 406 - repo.Created = createdAtTime 407 339 408 - if description.Valid { 409 - repo.Description = description.String 340 + whereClause := "" 341 + if conditions != nil { 342 + whereClause = " where " + strings.Join(conditions, " and ") 410 343 } 411 344 412 - if spindle.Valid { 413 - repo.Spindle = spindle.String 345 + repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause) 346 + var count int64 347 + err := e.QueryRow(repoQuery, args...).Scan(&count) 348 + 349 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 350 + return 0, err 414 351 } 415 352 416 - return &repo, nil 353 + return count, nil 417 354 } 418 355 419 - func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) { 420 - var repo Repo 356 + func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 357 + var repo models.Repo 421 358 var nullableDescription sql.NullString 422 359 423 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 424 361 425 362 var createdAt string 426 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 427 364 return nil, err 428 365 } 429 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 438 375 return &repo, nil 439 376 } 440 377 441 - func AddRepo(e Execer, repo *Repo) error { 442 - _, err := e.Exec( 378 + func AddRepo(tx *sql.Tx, repo *models.Repo) error { 379 + _, err := tx.Exec( 443 380 `insert into repos 444 381 (did, name, knot, rkey, at_uri, description, source) 445 382 values (?, ?, ?, ?, ?, ?, ?)`, 446 383 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 447 384 ) 448 - return err 385 + if err != nil { 386 + return fmt.Errorf("failed to insert repo: %w", err) 387 + } 388 + 389 + for _, dl := range repo.Labels { 390 + if err := SubscribeLabel(tx, &models.RepoLabel{ 391 + RepoAt: repo.RepoAt(), 392 + LabelAt: syntax.ATURI(dl), 393 + }); err != nil { 394 + return fmt.Errorf("failed to subscribe to label: %w", err) 395 + } 396 + } 397 + 398 + return nil 449 399 } 450 400 451 401 func RemoveRepo(e Execer, did, name string) error { ··· 462 412 return nullableSource.String, nil 463 413 } 464 414 465 - func GetForksByDid(e Execer, did string) ([]Repo, error) { 466 - var repos []Repo 415 + func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 416 + var repos []models.Repo 467 417 468 418 rows, err := e.Query( 469 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 470 420 from repos r 471 421 left join collaborators c on r.at_uri = c.repo_at 472 422 where (r.did = ? or c.subject_did = ?) ··· 481 431 defer rows.Close() 482 432 483 433 for rows.Next() { 484 - var repo Repo 434 + var repo models.Repo 485 435 var createdAt string 486 436 var nullableDescription sql.NullString 487 437 var nullableSource sql.NullString 488 438 489 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 490 440 if err != nil { 491 441 return nil, err 492 442 } ··· 516 466 return repos, nil 517 467 } 518 468 519 - func GetForkByDid(e Execer, did string, name string) (*Repo, error) { 520 - var repo Repo 469 + func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) { 470 + var repo models.Repo 521 471 var createdAt string 522 472 var nullableDescription sql.NullString 523 473 var nullableSource sql.NullString 524 474 525 475 row := e.QueryRow( 526 - `select did, name, knot, rkey, description, created, source 476 + `select id, did, name, knot, rkey, description, created, source 527 477 from repos 528 478 where did = ? and name = ? and source is not null and source != ''`, 529 479 did, name, 530 480 ) 531 481 532 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 533 483 if err != nil { 534 484 return nil, err 535 485 } ··· 564 514 return err 565 515 } 566 516 567 - type RepoStats struct { 568 - Language string 569 - StarCount int 570 - IssueCount IssueCount 571 - PullCount PullCount 517 + func SubscribeLabel(e Execer, rl *models.RepoLabel) error { 518 + query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)` 519 + 520 + _, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String()) 521 + return err 522 + } 523 + 524 + func UnsubscribeLabel(e Execer, filters ...filter) error { 525 + var conditions []string 526 + var args []any 527 + for _, filter := range filters { 528 + conditions = append(conditions, filter.Condition()) 529 + args = append(args, filter.Arg()...) 530 + } 531 + 532 + whereClause := "" 533 + if conditions != nil { 534 + whereClause = " where " + strings.Join(conditions, " and ") 535 + } 536 + 537 + query := fmt.Sprintf(`delete from repo_labels %s`, whereClause) 538 + _, err := e.Exec(query, args...) 539 + return err 572 540 } 573 541 574 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 575 - var createdAt string 576 - var nullableDescription sql.NullString 577 - var nullableSource sql.NullString 578 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 579 - return err 542 + func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) { 543 + var conditions []string 544 + var args []any 545 + for _, filter := range filters { 546 + conditions = append(conditions, filter.Condition()) 547 + args = append(args, filter.Arg()...) 580 548 } 581 549 582 - if nullableDescription.Valid { 583 - *description = nullableDescription.String 584 - } else { 585 - *description = "" 550 + whereClause := "" 551 + if conditions != nil { 552 + whereClause = " where " + strings.Join(conditions, " and ") 586 553 } 587 554 588 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 555 + query := fmt.Sprintf(`select id, repo_at, label_at from repo_labels %s`, whereClause) 556 + 557 + rows, err := e.Query(query, args...) 589 558 if err != nil { 590 - *created = time.Now() 591 - } else { 592 - *created = createdAtTime 559 + return nil, err 593 560 } 561 + defer rows.Close() 594 562 595 - if nullableSource.Valid { 596 - *source = nullableSource.String 597 - } else { 598 - *source = "" 563 + var labels []models.RepoLabel 564 + for rows.Next() { 565 + var label models.RepoLabel 566 + 567 + err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt) 568 + if err != nil { 569 + return nil, err 570 + } 571 + 572 + labels = append(labels, label) 599 573 } 600 574 601 - return nil 575 + if err = rows.Err(); err != nil { 576 + return nil, err 577 + } 578 + 579 + return labels, nil 602 580 }
+4 -9
appview/db/signup.go
··· 1 1 package db 2 2 3 - import "time" 3 + import ( 4 + "tangled.org/core/appview/models" 5 + ) 4 6 5 - type InflightSignup struct { 6 - Id int64 7 - Email string 8 - InviteCode string 9 - Created time.Time 10 - } 11 - 12 - func AddInflightSignup(e Execer, signup InflightSignup) error { 7 + func AddInflightSignup(e Execer, signup models.InflightSignup) error { 13 8 query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 9 _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 10 return err
+17 -28
appview/db/spindle.go
··· 6 6 "strings" 7 7 "time" 8 8 9 - "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/appview/models" 10 10 ) 11 11 12 - type Spindle struct { 13 - Id int 14 - Owner syntax.DID 15 - Instance string 16 - Verified *time.Time 17 - Created time.Time 18 - } 19 - 20 - type SpindleMember struct { 21 - Id int 22 - Did syntax.DID // owner of the record 23 - Rkey string // rkey of the record 24 - Instance string 25 - Subject syntax.DID // the member being added 26 - Created time.Time 27 - } 28 - 29 - func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) { 30 - var spindles []Spindle 12 + func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) { 13 + var spindles []models.Spindle 31 14 32 15 var conditions []string 33 16 var args []any ··· 42 25 } 43 26 44 27 query := fmt.Sprintf( 45 - `select id, owner, instance, verified, created 28 + `select id, owner, instance, verified, created, needs_upgrade 46 29 from spindles 47 30 %s 48 31 order by created ··· 58 41 defer rows.Close() 59 42 60 43 for rows.Next() { 61 - var spindle Spindle 44 + var spindle models.Spindle 62 45 var createdAt string 63 46 var verified sql.NullString 47 + var needsUpgrade int 64 48 65 49 if err := rows.Scan( 66 50 &spindle.Id, ··· 68 52 &spindle.Instance, 69 53 &verified, 70 54 &createdAt, 55 + &needsUpgrade, 71 56 ); err != nil { 72 57 return nil, err 73 58 } ··· 86 71 spindle.Verified = &t 87 72 } 88 73 74 + if needsUpgrade != 0 { 75 + spindle.NeedsUpgrade = true 76 + } 77 + 89 78 spindles = append(spindles, spindle) 90 79 } 91 80 ··· 93 82 } 94 83 95 84 // if there is an existing spindle with the same instance, this returns an error 96 - func AddSpindle(e Execer, spindle Spindle) error { 85 + func AddSpindle(e Execer, spindle models.Spindle) error { 97 86 _, err := e.Exec( 98 87 `insert into spindles (owner, instance) values (?, ?)`, 99 88 spindle.Owner, ··· 115 104 whereClause = " where " + strings.Join(conditions, " and ") 116 105 } 117 106 118 - query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 107 + query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'), needs_upgrade = 0 %s`, whereClause) 119 108 120 109 res, err := e.Exec(query, args...) 121 110 if err != nil { ··· 144 133 return err 145 134 } 146 135 147 - func AddSpindleMember(e Execer, member SpindleMember) error { 136 + func AddSpindleMember(e Execer, member models.SpindleMember) error { 148 137 _, err := e.Exec( 149 138 `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 150 139 member.Did, ··· 174 163 return err 175 164 } 176 165 177 - func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) { 178 - var members []SpindleMember 166 + func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) { 167 + var members []models.SpindleMember 179 168 180 169 var conditions []string 181 170 var args []any ··· 206 195 defer rows.Close() 207 196 208 197 for rows.Next() { 209 - var member SpindleMember 198 + var member models.SpindleMember 210 199 var createdAt string 211 200 212 201 if err := rows.Scan(
+106 -42
appview/db/star.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 5 + "errors" 4 6 "fmt" 5 7 "log" 8 + "slices" 6 9 "strings" 7 10 "time" 8 11 9 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/appview/models" 10 14 ) 11 15 12 - type Star struct { 13 - StarredByDid string 14 - RepoAt syntax.ATURI 15 - Created time.Time 16 - Rkey string 17 - 18 - // optionally, populate this when querying for reverse mappings 19 - Repo *Repo 20 - } 21 - 22 - func (star *Star) ResolveRepo(e Execer) error { 23 - if star.Repo != nil { 24 - return nil 25 - } 26 - 27 - repo, err := GetRepoByAtUri(e, star.RepoAt.String()) 28 - if err != nil { 29 - return err 30 - } 31 - 32 - star.Repo = repo 33 - return nil 34 - } 35 - 36 - func AddStar(e Execer, star *Star) error { 16 + func AddStar(e Execer, star *models.Star) error { 37 17 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 38 18 _, err := e.Exec( 39 19 query, ··· 45 25 } 46 26 47 27 // Get a star record 48 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 28 + func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 49 29 query := ` 50 30 select starred_by_did, repo_at, created, rkey 51 31 from stars 52 32 where starred_by_did = ? and repo_at = ?` 53 33 row := e.QueryRow(query, starredByDid, repoAt) 54 34 55 - var star Star 35 + var star models.Star 56 36 var created string 57 37 err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 58 38 if err != nil { ··· 92 72 return stars, nil 93 73 } 94 74 75 + // getStarStatuses returns a map of repo URIs to star status for a given user 76 + // This is an internal helper function to avoid N+1 queries 77 + func getStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) { 78 + if len(repoAts) == 0 || userDid == "" { 79 + return make(map[string]bool), nil 80 + } 81 + 82 + placeholders := make([]string, len(repoAts)) 83 + args := make([]any, len(repoAts)+1) 84 + args[0] = userDid 85 + 86 + for i, repoAt := range repoAts { 87 + placeholders[i] = "?" 88 + args[i+1] = repoAt.String() 89 + } 90 + 91 + query := fmt.Sprintf(` 92 + SELECT repo_at 93 + FROM stars 94 + WHERE starred_by_did = ? AND repo_at IN (%s) 95 + `, strings.Join(placeholders, ",")) 96 + 97 + rows, err := e.Query(query, args...) 98 + if err != nil { 99 + return nil, err 100 + } 101 + defer rows.Close() 102 + 103 + result := make(map[string]bool) 104 + // Initialize all repos as not starred 105 + for _, repoAt := range repoAts { 106 + result[repoAt.String()] = false 107 + } 108 + 109 + // Mark starred repos as true 110 + for rows.Next() { 111 + var repoAt string 112 + if err := rows.Scan(&repoAt); err != nil { 113 + return nil, err 114 + } 115 + result[repoAt] = true 116 + } 117 + 118 + return result, nil 119 + } 120 + 95 121 func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool { 96 - if _, err := GetStar(e, userDid, repoAt); err != nil { 122 + statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt}) 123 + if err != nil { 97 124 return false 98 - } else { 99 - return true 100 125 } 126 + return statuses[repoAt.String()] 101 127 } 102 128 103 - func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) { 129 + // GetStarStatuses returns a map of repo URIs to star status for a given user 130 + func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) { 131 + return getStarStatuses(e, userDid, repoAts) 132 + } 133 + func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) { 104 134 var conditions []string 105 135 var args []any 106 136 for _, filter := range filters { ··· 132 162 return nil, err 133 163 } 134 164 135 - starMap := make(map[string][]Star) 165 + starMap := make(map[string][]models.Star) 136 166 for rows.Next() { 137 - var star Star 167 + var star models.Star 138 168 var created string 139 169 err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 140 170 if err != nil { ··· 175 205 } 176 206 } 177 207 178 - var stars []Star 208 + var stars []models.Star 179 209 for _, s := range starMap { 180 210 stars = append(stars, s...) 181 211 } 182 212 213 + slices.SortFunc(stars, func(a, b models.Star) int { 214 + if a.Created.After(b.Created) { 215 + return -1 216 + } 217 + if b.Created.After(a.Created) { 218 + return 1 219 + } 220 + return 0 221 + }) 222 + 183 223 return stars, nil 184 224 } 185 225 186 - func GetAllStars(e Execer, limit int) ([]Star, error) { 187 - var stars []Star 226 + func CountStars(e Execer, filters ...filter) (int64, error) { 227 + var conditions []string 228 + var args []any 229 + for _, filter := range filters { 230 + conditions = append(conditions, filter.Condition()) 231 + args = append(args, filter.Arg()...) 232 + } 233 + 234 + whereClause := "" 235 + if conditions != nil { 236 + whereClause = " where " + strings.Join(conditions, " and ") 237 + } 238 + 239 + repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause) 240 + var count int64 241 + err := e.QueryRow(repoQuery, args...).Scan(&count) 242 + 243 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 244 + return 0, err 245 + } 246 + 247 + return count, nil 248 + } 249 + 250 + func GetAllStars(e Execer, limit int) ([]models.Star, error) { 251 + var stars []models.Star 188 252 189 253 rows, err := e.Query(` 190 254 select ··· 207 271 defer rows.Close() 208 272 209 273 for rows.Next() { 210 - var star Star 211 - var repo Repo 274 + var star models.Star 275 + var repo models.Repo 212 276 var starCreatedAt, repoCreatedAt string 213 277 214 278 if err := rows.Scan( ··· 246 310 } 247 311 248 312 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 249 - func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 313 + func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 250 314 // first, get the top repo URIs by star count from the last week 251 315 query := ` 252 316 with recent_starred_repos as ( ··· 290 354 } 291 355 292 356 if len(repoUris) == 0 { 293 - return []Repo{}, nil 357 + return []models.Repo{}, nil 294 358 } 295 359 296 360 // get full repo data ··· 300 364 } 301 365 302 366 // sort repos by the original trending order 303 - repoMap := make(map[string]Repo) 367 + repoMap := make(map[string]models.Repo) 304 368 for _, repo := range repos { 305 369 repoMap[repo.RepoAt().String()] = repo 306 370 } 307 371 308 - orderedRepos := make([]Repo, 0, len(repoUris)) 372 + orderedRepos := make([]models.Repo, 0, len(repoUris)) 309 373 for _, uri := range repoUris { 310 374 if repo, exists := repoMap[uri]; exists { 311 375 orderedRepos = append(orderedRepos, repo)
+29 -110
appview/db/strings.go
··· 1 1 package db 2 2 3 3 import ( 4 - "bytes" 5 4 "database/sql" 6 5 "errors" 7 6 "fmt" 8 - "io" 9 7 "strings" 10 8 "time" 11 - "unicode/utf8" 12 9 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.org/core/appview/models" 15 11 ) 16 12 17 - type String struct { 18 - Did syntax.DID 19 - Rkey string 20 - 21 - Filename string 22 - Description string 23 - Contents string 24 - Created time.Time 25 - Edited *time.Time 26 - } 27 - 28 - func (s *String) StringAt() syntax.ATURI { 29 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 30 - } 31 - 32 - type StringStats struct { 33 - LineCount uint64 34 - ByteCount uint64 35 - } 36 - 37 - func (s String) Stats() StringStats { 38 - lineCount, err := countLines(strings.NewReader(s.Contents)) 39 - if err != nil { 40 - // non-fatal 41 - // TODO: log this? 42 - } 43 - 44 - return StringStats{ 45 - LineCount: uint64(lineCount), 46 - ByteCount: uint64(len(s.Contents)), 47 - } 48 - } 49 - 50 - func (s String) Validate() error { 51 - var err error 52 - 53 - if utf8.RuneCountInString(s.Filename) > 140 { 54 - err = errors.Join(err, fmt.Errorf("filename too long")) 55 - } 56 - 57 - if utf8.RuneCountInString(s.Description) > 280 { 58 - err = errors.Join(err, fmt.Errorf("description too long")) 59 - } 60 - 61 - if len(s.Contents) == 0 { 62 - err = errors.Join(err, fmt.Errorf("contents is empty")) 63 - } 64 - 65 - return err 66 - } 67 - 68 - func (s *String) AsRecord() tangled.String { 69 - return tangled.String{ 70 - Filename: s.Filename, 71 - Description: s.Description, 72 - Contents: s.Contents, 73 - CreatedAt: s.Created.Format(time.RFC3339), 74 - } 75 - } 76 - 77 - func StringFromRecord(did, rkey string, record tangled.String) String { 78 - created, err := time.Parse(record.CreatedAt, time.RFC3339) 79 - if err != nil { 80 - created = time.Now() 81 - } 82 - return String{ 83 - Did: syntax.DID(did), 84 - Rkey: rkey, 85 - Filename: record.Filename, 86 - Description: record.Description, 87 - Contents: record.Contents, 88 - Created: created, 89 - } 90 - } 91 - 92 - func AddString(e Execer, s String) error { 13 + func AddString(e Execer, s models.String) error { 93 14 _, err := e.Exec( 94 15 `insert into strings ( 95 16 did, ··· 123 44 return err 124 45 } 125 46 126 - func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 127 - var all []String 47 + func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) { 48 + var all []models.String 128 49 129 50 var conditions []string 130 51 var args []any ··· 167 88 defer rows.Close() 168 89 169 90 for rows.Next() { 170 - var s String 91 + var s models.String 171 92 var createdAt string 172 93 var editedAt sql.NullString 173 94 ··· 206 127 return all, nil 207 128 } 208 129 130 + func CountStrings(e Execer, filters ...filter) (int64, error) { 131 + var conditions []string 132 + var args []any 133 + for _, filter := range filters { 134 + conditions = append(conditions, filter.Condition()) 135 + args = append(args, filter.Arg()...) 136 + } 137 + 138 + whereClause := "" 139 + if conditions != nil { 140 + whereClause = " where " + strings.Join(conditions, " and ") 141 + } 142 + 143 + repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause) 144 + var count int64 145 + err := e.QueryRow(repoQuery, args...).Scan(&count) 146 + 147 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 148 + return 0, err 149 + } 150 + 151 + return count, nil 152 + } 153 + 209 154 func DeleteString(e Execer, filters ...filter) error { 210 155 var conditions []string 211 156 var args []any ··· 224 169 _, err := e.Exec(query, args...) 225 170 return err 226 171 } 227 - 228 - func countLines(r io.Reader) (int, error) { 229 - buf := make([]byte, 32*1024) 230 - bufLen := 0 231 - count := 0 232 - nl := []byte{'\n'} 233 - 234 - for { 235 - c, err := r.Read(buf) 236 - if c > 0 { 237 - bufLen += c 238 - } 239 - count += bytes.Count(buf[:c], nl) 240 - 241 - switch { 242 - case err == io.EOF: 243 - /* handle last line not having a newline at the end */ 244 - if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 245 - count++ 246 - } 247 - return count, nil 248 - case err != nil: 249 - return 0, err 250 - } 251 - } 252 - }
+126 -49
appview/db/timeline.go
··· 2 2 3 3 import ( 4 4 "sort" 5 - "time" 6 - ) 7 5 8 - type TimelineEvent struct { 9 - *Repo 10 - *Follow 11 - *Star 12 - 13 - EventAt time.Time 14 - 15 - // optional: populate only if Repo is a fork 16 - Source *Repo 17 - 18 - // optional: populate only if event is Follow 19 - *Profile 20 - *FollowStats 21 - } 22 - 23 - const Limit = 50 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/appview/models" 8 + ) 24 9 25 10 // TODO: this gathers heterogenous events from different sources and aggregates 26 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 27 - func MakeTimeline(e Execer) ([]TimelineEvent, error) { 28 - var events []TimelineEvent 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 13 + var events []models.TimelineEvent 14 + 15 + var userIsFollowing []string 16 + if limitToUsersIsFollowing { 17 + following, err := GetFollowing(e, loggedInUserDid) 18 + if err != nil { 19 + return nil, err 20 + } 29 21 30 - repos, err := getTimelineRepos(e) 22 + userIsFollowing = make([]string, 0, len(following)) 23 + for _, follow := range following { 24 + userIsFollowing = append(userIsFollowing, follow.SubjectDid) 25 + } 26 + } 27 + 28 + repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) 31 29 if err != nil { 32 30 return nil, err 33 31 } 34 32 35 - stars, err := getTimelineStars(e) 33 + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 36 34 if err != nil { 37 35 return nil, err 38 36 } 39 37 40 - follows, err := getTimelineFollows(e) 38 + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 41 39 if err != nil { 42 40 return nil, err 43 41 } ··· 51 49 }) 52 50 53 51 // Limit the slice to 100 events 54 - if len(events) > Limit { 55 - events = events[:Limit] 52 + if len(events) > limit { 53 + events = events[:limit] 56 54 } 57 55 58 56 return events, nil 59 57 } 60 58 61 - func getTimelineRepos(e Execer) ([]TimelineEvent, error) { 62 - repos, err := GetRepos(e, Limit) 59 + func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) { 60 + if loggedInUserDid == "" { 61 + return nil, nil 62 + } 63 + 64 + var repoAts []syntax.ATURI 65 + for _, r := range repos { 66 + repoAts = append(repoAts, r.RepoAt()) 67 + } 68 + 69 + return GetStarStatuses(e, loggedInUserDid, repoAts) 70 + } 71 + 72 + func getRepoStarInfo(repo *models.Repo, starStatuses map[string]bool) (bool, int64) { 73 + var isStarred bool 74 + if starStatuses != nil { 75 + isStarred = starStatuses[repo.RepoAt().String()] 76 + } 77 + 78 + var starCount int64 79 + if repo.RepoStats != nil { 80 + starCount = int64(repo.RepoStats.StarCount) 81 + } 82 + 83 + return isStarred, starCount 84 + } 85 + 86 + func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 87 + filters := make([]filter, 0) 88 + if userIsFollowing != nil { 89 + filters = append(filters, FilterIn("did", userIsFollowing)) 90 + } 91 + 92 + repos, err := GetRepos(e, limit, filters...) 63 93 if err != nil { 64 94 return nil, err 65 95 } ··· 72 102 } 73 103 } 74 104 75 - var origRepos []Repo 105 + var origRepos []models.Repo 76 106 if args != nil { 77 107 origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 78 108 } ··· 80 110 return nil, err 81 111 } 82 112 83 - uriToRepo := make(map[string]Repo) 113 + uriToRepo := make(map[string]models.Repo) 84 114 for _, r := range origRepos { 85 115 uriToRepo[r.RepoAt().String()] = r 86 116 } 87 117 88 - var events []TimelineEvent 118 + starStatuses, err := fetchStarStatuses(e, loggedInUserDid, repos) 119 + if err != nil { 120 + return nil, err 121 + } 122 + 123 + var events []models.TimelineEvent 89 124 for _, r := range repos { 90 - var source *Repo 125 + var source *models.Repo 91 126 if r.Source != "" { 92 127 if origRepo, ok := uriToRepo[r.Source]; ok { 93 128 source = &origRepo 94 129 } 95 130 } 96 131 97 - events = append(events, TimelineEvent{ 98 - Repo: &r, 99 - EventAt: r.Created, 100 - Source: source, 132 + isStarred, starCount := getRepoStarInfo(&r, starStatuses) 133 + 134 + events = append(events, models.TimelineEvent{ 135 + Repo: &r, 136 + EventAt: r.Created, 137 + Source: source, 138 + IsStarred: isStarred, 139 + StarCount: starCount, 101 140 }) 102 141 } 103 142 104 143 return events, nil 105 144 } 106 145 107 - func getTimelineStars(e Execer) ([]TimelineEvent, error) { 108 - stars, err := GetStars(e, Limit) 146 + func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 + filters := make([]filter, 0) 148 + if userIsFollowing != nil { 149 + filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 150 + } 151 + 152 + stars, err := GetStars(e, limit, filters...) 109 153 if err != nil { 110 154 return nil, err 111 155 } ··· 120 164 } 121 165 stars = stars[:n] 122 166 123 - var events []TimelineEvent 167 + var repos []models.Repo 124 168 for _, s := range stars { 125 - events = append(events, TimelineEvent{ 126 - Star: &s, 127 - EventAt: s.Created, 169 + repos = append(repos, *s.Repo) 170 + } 171 + 172 + starStatuses, err := fetchStarStatuses(e, loggedInUserDid, repos) 173 + if err != nil { 174 + return nil, err 175 + } 176 + 177 + var events []models.TimelineEvent 178 + for _, s := range stars { 179 + isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 180 + 181 + events = append(events, models.TimelineEvent{ 182 + Star: &s, 183 + EventAt: s.Created, 184 + IsStarred: isStarred, 185 + StarCount: starCount, 128 186 }) 129 187 } 130 188 131 189 return events, nil 132 190 } 133 191 134 - func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 135 - follows, err := GetFollows(e, Limit) 192 + func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 193 + filters := make([]filter, 0) 194 + if userIsFollowing != nil { 195 + filters = append(filters, FilterIn("user_did", userIsFollowing)) 196 + } 197 + 198 + follows, err := GetFollows(e, limit, filters...) 136 199 if err != nil { 137 200 return nil, err 138 201 } ··· 156 219 return nil, err 157 220 } 158 221 159 - var events []TimelineEvent 222 + var followStatuses map[string]models.FollowStatus 223 + if loggedInUserDid != "" { 224 + followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects) 225 + if err != nil { 226 + return nil, err 227 + } 228 + } 229 + 230 + var events []models.TimelineEvent 160 231 for _, f := range follows { 161 232 profile, _ := profiles[f.SubjectDid] 162 233 followStatMap, _ := followStatMap[f.SubjectDid] 163 234 164 - events = append(events, TimelineEvent{ 165 - Follow: &f, 166 - Profile: profile, 167 - FollowStats: &followStatMap, 168 - EventAt: f.FollowedAt, 235 + followStatus := models.IsNotFollowing 236 + if followStatuses != nil { 237 + followStatus = followStatuses[f.SubjectDid] 238 + } 239 + 240 + events = append(events, models.TimelineEvent{ 241 + Follow: &f, 242 + Profile: profile, 243 + FollowStats: &followStatMap, 244 + FollowStatus: &followStatus, 245 + EventAt: f.FollowedAt, 169 246 }) 170 247 } 171 248
+1 -1
appview/dns/cloudflare.go
··· 5 5 "fmt" 6 6 7 7 "github.com/cloudflare/cloudflare-go" 8 - "tangled.sh/tangled.sh/core/appview/config" 8 + "tangled.org/core/appview/config" 9 9 ) 10 10 11 11 type Record struct {
+210 -118
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 - "strings" 8 + "maps" 9 + "slices" 10 + 9 11 "time" 10 12 11 13 "github.com/bluesky-social/indigo/atproto/syntax" 12 - "github.com/bluesky-social/jetstream/pkg/models" 14 + jmodels "github.com/bluesky-social/jetstream/pkg/models" 13 15 "github.com/go-git/go-git/v5/plumbing" 14 16 "github.com/ipfs/go-cid" 15 - "tangled.sh/tangled.sh/core/api/tangled" 16 - "tangled.sh/tangled.sh/core/appview/config" 17 - "tangled.sh/tangled.sh/core/appview/db" 18 - "tangled.sh/tangled.sh/core/appview/pages/markup" 19 - "tangled.sh/tangled.sh/core/appview/serververify" 20 - "tangled.sh/tangled.sh/core/idresolver" 21 - "tangled.sh/tangled.sh/core/rbac" 17 + "tangled.org/core/api/tangled" 18 + "tangled.org/core/appview/config" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/serververify" 22 + "tangled.org/core/appview/validator" 23 + "tangled.org/core/idresolver" 24 + "tangled.org/core/rbac" 22 25 ) 23 26 24 27 type Ingester struct { ··· 27 30 IdResolver *idresolver.Resolver 28 31 Config *config.Config 29 32 Logger *slog.Logger 33 + Validator *validator.Validator 30 34 } 31 35 32 - type processFunc func(ctx context.Context, e *models.Event) error 36 + type processFunc func(ctx context.Context, e *jmodels.Event) error 33 37 34 38 func (i *Ingester) Ingest() processFunc { 35 - return func(ctx context.Context, e *models.Event) error { 39 + return func(ctx context.Context, e *jmodels.Event) error { 36 40 var err error 37 41 defer func() { 38 42 eventTime := e.TimeUS ··· 44 48 45 49 l := i.Logger.With("kind", e.Kind) 46 50 switch e.Kind { 47 - case models.EventKindAccount: 51 + case jmodels.EventKindAccount: 48 52 if !e.Account.Active && *e.Account.Status == "deactivated" { 49 53 err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did) 50 54 } 51 - case models.EventKindIdentity: 55 + case jmodels.EventKindIdentity: 52 56 err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did) 53 - case models.EventKindCommit: 57 + case jmodels.EventKindCommit: 54 58 switch e.Commit.Collection { 55 59 case tangled.GraphFollowNSID: 56 60 err = i.ingestFollow(e) ··· 76 80 err = i.ingestIssue(ctx, e) 77 81 case tangled.RepoIssueCommentNSID: 78 82 err = i.ingestIssueComment(e) 83 + case tangled.LabelDefinitionNSID: 84 + err = i.ingestLabelDefinition(e) 85 + case tangled.LabelOpNSID: 86 + err = i.ingestLabelOp(e) 79 87 } 80 88 l = i.Logger.With("nsid", e.Commit.Collection) 81 89 } ··· 88 96 } 89 97 } 90 98 91 - func (i *Ingester) ingestStar(e *models.Event) error { 99 + func (i *Ingester) ingestStar(e *jmodels.Event) error { 92 100 var err error 93 101 did := e.Did 94 102 ··· 96 104 l = l.With("nsid", e.Commit.Collection) 97 105 98 106 switch e.Commit.Operation { 99 - case models.CommitOperationCreate, models.CommitOperationUpdate: 107 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 100 108 var subjectUri syntax.ATURI 101 109 102 110 raw := json.RawMessage(e.Commit.Record) ··· 112 120 l.Error("invalid record", "err", err) 113 121 return err 114 122 } 115 - err = db.AddStar(i.Db, &db.Star{ 123 + err = db.AddStar(i.Db, &models.Star{ 116 124 StarredByDid: did, 117 125 RepoAt: subjectUri, 118 126 Rkey: e.Commit.RKey, 119 127 }) 120 - case models.CommitOperationDelete: 128 + case jmodels.CommitOperationDelete: 121 129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 122 130 } 123 131 ··· 128 136 return nil 129 137 } 130 138 131 - func (i *Ingester) ingestFollow(e *models.Event) error { 139 + func (i *Ingester) ingestFollow(e *jmodels.Event) error { 132 140 var err error 133 141 did := e.Did 134 142 ··· 136 144 l = l.With("nsid", e.Commit.Collection) 137 145 138 146 switch e.Commit.Operation { 139 - case models.CommitOperationCreate, models.CommitOperationUpdate: 147 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 140 148 raw := json.RawMessage(e.Commit.Record) 141 149 record := tangled.GraphFollow{} 142 150 err = json.Unmarshal(raw, &record) ··· 145 153 return err 146 154 } 147 155 148 - err = db.AddFollow(i.Db, &db.Follow{ 156 + err = db.AddFollow(i.Db, &models.Follow{ 149 157 UserDid: did, 150 158 SubjectDid: record.Subject, 151 159 Rkey: e.Commit.RKey, 152 160 }) 153 - case models.CommitOperationDelete: 161 + case jmodels.CommitOperationDelete: 154 162 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 155 163 } 156 164 ··· 161 169 return nil 162 170 } 163 171 164 - func (i *Ingester) ingestPublicKey(e *models.Event) error { 172 + func (i *Ingester) ingestPublicKey(e *jmodels.Event) error { 165 173 did := e.Did 166 174 var err error 167 175 ··· 169 177 l = l.With("nsid", e.Commit.Collection) 170 178 171 179 switch e.Commit.Operation { 172 - case models.CommitOperationCreate, models.CommitOperationUpdate: 180 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 173 181 l.Debug("processing add of pubkey") 174 182 raw := json.RawMessage(e.Commit.Record) 175 183 record := tangled.PublicKey{} ··· 182 190 name := record.Name 183 191 key := record.Key 184 192 err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) 185 - case models.CommitOperationDelete: 193 + case jmodels.CommitOperationDelete: 186 194 l.Debug("processing delete of pubkey") 187 195 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) 188 196 } ··· 194 202 return nil 195 203 } 196 204 197 - func (i *Ingester) ingestArtifact(e *models.Event) error { 205 + func (i *Ingester) ingestArtifact(e *jmodels.Event) error { 198 206 did := e.Did 199 207 var err error 200 208 ··· 202 210 l = l.With("nsid", e.Commit.Collection) 203 211 204 212 switch e.Commit.Operation { 205 - case models.CommitOperationCreate, models.CommitOperationUpdate: 213 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 206 214 raw := json.RawMessage(e.Commit.Record) 207 215 record := tangled.RepoArtifact{} 208 216 err = json.Unmarshal(raw, &record) ··· 231 239 createdAt = time.Now() 232 240 } 233 241 234 - artifact := db.Artifact{ 242 + artifact := models.Artifact{ 235 243 Did: did, 236 244 Rkey: e.Commit.RKey, 237 245 RepoAt: repoAt, ··· 244 252 } 245 253 246 254 err = db.AddArtifact(i.Db, artifact) 247 - case models.CommitOperationDelete: 255 + case jmodels.CommitOperationDelete: 248 256 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 249 257 } 250 258 ··· 255 263 return nil 256 264 } 257 265 258 - func (i *Ingester) ingestProfile(e *models.Event) error { 266 + func (i *Ingester) ingestProfile(e *jmodels.Event) error { 259 267 did := e.Did 260 268 var err error 261 269 ··· 267 275 } 268 276 269 277 switch e.Commit.Operation { 270 - case models.CommitOperationCreate, models.CommitOperationUpdate: 278 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 271 279 raw := json.RawMessage(e.Commit.Record) 272 280 record := tangled.ActorProfile{} 273 281 err = json.Unmarshal(raw, &record) ··· 295 303 } 296 304 } 297 305 298 - var stats [2]db.VanityStat 306 + var stats [2]models.VanityStat 299 307 for i, s := range record.Stats { 300 308 if i < 2 { 301 - stats[i].Kind = db.VanityStatKind(s) 309 + stats[i].Kind = models.VanityStatKind(s) 302 310 } 303 311 } 304 312 ··· 309 317 } 310 318 } 311 319 312 - profile := db.Profile{ 320 + profile := models.Profile{ 313 321 Did: did, 314 322 Description: description, 315 323 IncludeBluesky: includeBluesky, ··· 335 343 } 336 344 337 345 err = db.UpsertProfile(tx, &profile) 338 - case models.CommitOperationDelete: 346 + case jmodels.CommitOperationDelete: 339 347 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 340 348 } 341 349 ··· 346 354 return nil 347 355 } 348 356 349 - func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 357 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event) error { 350 358 did := e.Did 351 359 var err error 352 360 ··· 354 362 l = l.With("nsid", e.Commit.Collection) 355 363 356 364 switch e.Commit.Operation { 357 - case models.CommitOperationCreate: 365 + case jmodels.CommitOperationCreate: 358 366 raw := json.RawMessage(e.Commit.Record) 359 367 record := tangled.SpindleMember{} 360 368 err = json.Unmarshal(raw, &record) ··· 383 391 return fmt.Errorf("failed to index profile record, invalid db cast") 384 392 } 385 393 386 - err = db.AddSpindleMember(ddb, db.SpindleMember{ 394 + err = db.AddSpindleMember(ddb, models.SpindleMember{ 387 395 Did: syntax.DID(did), 388 396 Rkey: e.Commit.RKey, 389 397 Instance: record.Instance, ··· 399 407 } 400 408 401 409 l.Info("added spindle member") 402 - case models.CommitOperationDelete: 410 + case jmodels.CommitOperationDelete: 403 411 rkey := e.Commit.RKey 404 412 405 413 ddb, ok := i.Db.Execer.(*db.DB) ··· 452 460 return nil 453 461 } 454 462 455 - func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 463 + func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event) error { 456 464 did := e.Did 457 465 var err error 458 466 ··· 460 468 l = l.With("nsid", e.Commit.Collection) 461 469 462 470 switch e.Commit.Operation { 463 - case models.CommitOperationCreate: 471 + case jmodels.CommitOperationCreate: 464 472 raw := json.RawMessage(e.Commit.Record) 465 473 record := tangled.Spindle{} 466 474 err = json.Unmarshal(raw, &record) ··· 476 484 return fmt.Errorf("failed to index profile record, invalid db cast") 477 485 } 478 486 479 - err := db.AddSpindle(ddb, db.Spindle{ 487 + err := db.AddSpindle(ddb, models.Spindle{ 480 488 Owner: syntax.DID(did), 481 489 Instance: instance, 482 490 }) ··· 498 506 499 507 return nil 500 508 501 - case models.CommitOperationDelete: 509 + case jmodels.CommitOperationDelete: 502 510 instance := e.Commit.RKey 503 511 504 512 ddb, ok := i.Db.Execer.(*db.DB) ··· 566 574 return nil 567 575 } 568 576 569 - func (i *Ingester) ingestString(e *models.Event) error { 577 + func (i *Ingester) ingestString(e *jmodels.Event) error { 570 578 did := e.Did 571 579 rkey := e.Commit.RKey 572 580 ··· 581 589 } 582 590 583 591 switch e.Commit.Operation { 584 - case models.CommitOperationCreate, models.CommitOperationUpdate: 592 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 585 593 raw := json.RawMessage(e.Commit.Record) 586 594 record := tangled.String{} 587 595 err = json.Unmarshal(raw, &record) ··· 590 598 return err 591 599 } 592 600 593 - string := db.StringFromRecord(did, rkey, record) 601 + string := models.StringFromRecord(did, rkey, record) 594 602 595 - if err = string.Validate(); err != nil { 603 + if err = i.Validator.ValidateString(&string); err != nil { 596 604 l.Error("invalid record", "err", err) 597 605 return err 598 606 } ··· 604 612 605 613 return nil 606 614 607 - case models.CommitOperationDelete: 615 + case jmodels.CommitOperationDelete: 608 616 if err := db.DeleteString( 609 617 ddb, 610 618 db.FilterEq("did", did), ··· 620 628 return nil 621 629 } 622 630 623 - func (i *Ingester) ingestKnotMember(e *models.Event) error { 631 + func (i *Ingester) ingestKnotMember(e *jmodels.Event) error { 624 632 did := e.Did 625 633 var err error 626 634 ··· 628 636 l = l.With("nsid", e.Commit.Collection) 629 637 630 638 switch e.Commit.Operation { 631 - case models.CommitOperationCreate: 639 + case jmodels.CommitOperationCreate: 632 640 raw := json.RawMessage(e.Commit.Record) 633 641 record := tangled.KnotMember{} 634 642 err = json.Unmarshal(raw, &record) ··· 658 666 } 659 667 660 668 l.Info("added knot member") 661 - case models.CommitOperationDelete: 669 + case jmodels.CommitOperationDelete: 662 670 // we don't store knot members in a table (like we do for spindle) 663 671 // and we can't remove this just yet. possibly fixed if we switch 664 672 // to either: ··· 672 680 return nil 673 681 } 674 682 675 - func (i *Ingester) ingestKnot(e *models.Event) error { 683 + func (i *Ingester) ingestKnot(e *jmodels.Event) error { 676 684 did := e.Did 677 685 var err error 678 686 ··· 680 688 l = l.With("nsid", e.Commit.Collection) 681 689 682 690 switch e.Commit.Operation { 683 - case models.CommitOperationCreate: 691 + case jmodels.CommitOperationCreate: 684 692 raw := json.RawMessage(e.Commit.Record) 685 693 record := tangled.Knot{} 686 694 err = json.Unmarshal(raw, &record) ··· 715 723 716 724 return nil 717 725 718 - case models.CommitOperationDelete: 726 + case jmodels.CommitOperationDelete: 719 727 domain := e.Commit.RKey 720 728 721 729 ddb, ok := i.Db.Execer.(*db.DB) ··· 775 783 776 784 return nil 777 785 } 778 - func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 786 + func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error { 779 787 did := e.Did 780 788 rkey := e.Commit.RKey 781 789 ··· 790 798 } 791 799 792 800 switch e.Commit.Operation { 793 - case models.CommitOperationCreate: 801 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 794 802 raw := json.RawMessage(e.Commit.Record) 795 803 record := tangled.RepoIssue{} 796 804 err = json.Unmarshal(raw, &record) ··· 799 807 return err 800 808 } 801 809 802 - issue := db.IssueFromRecord(did, rkey, record) 810 + issue := models.IssueFromRecord(did, rkey, record) 803 811 804 - sanitizer := markup.NewSanitizer() 805 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { 806 - return fmt.Errorf("title is empty after HTML sanitization") 807 - } 808 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { 809 - return fmt.Errorf("body is empty after HTML sanitization") 812 + if err := i.Validator.ValidateIssue(&issue); err != nil { 813 + return fmt.Errorf("failed to validate issue: %w", err) 810 814 } 811 815 812 816 tx, err := ddb.BeginTx(ctx, nil) ··· 814 818 l.Error("failed to begin transaction", "err", err) 815 819 return err 816 820 } 821 + defer tx.Rollback() 817 822 818 - err = db.NewIssue(tx, &issue) 823 + err = db.PutIssue(tx, &issue) 819 824 if err != nil { 820 825 l.Error("failed to create issue", "err", err) 821 826 return err 822 827 } 823 828 829 + err = tx.Commit() 830 + if err != nil { 831 + l.Error("failed to commit txn", "err", err) 832 + return err 833 + } 834 + 824 835 return nil 825 836 826 - case models.CommitOperationUpdate: 837 + case jmodels.CommitOperationDelete: 838 + if err := db.DeleteIssues( 839 + ddb, 840 + db.FilterEq("did", did), 841 + db.FilterEq("rkey", rkey), 842 + ); err != nil { 843 + l.Error("failed to delete", "err", err) 844 + return fmt.Errorf("failed to delete issue record: %w", err) 845 + } 846 + 847 + return nil 848 + } 849 + 850 + return nil 851 + } 852 + 853 + func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 854 + did := e.Did 855 + rkey := e.Commit.RKey 856 + 857 + var err error 858 + 859 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 860 + l.Info("ingesting record") 861 + 862 + ddb, ok := i.Db.Execer.(*db.DB) 863 + if !ok { 864 + return fmt.Errorf("failed to index issue comment record, invalid db cast") 865 + } 866 + 867 + switch e.Commit.Operation { 868 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 827 869 raw := json.RawMessage(e.Commit.Record) 828 - record := tangled.RepoIssue{} 870 + record := tangled.RepoIssueComment{} 829 871 err = json.Unmarshal(raw, &record) 830 872 if err != nil { 831 - l.Error("invalid record", "err", err) 832 - return err 873 + return fmt.Errorf("invalid record: %w", err) 833 874 } 834 875 835 - body := "" 836 - if record.Body != nil { 837 - body = *record.Body 876 + comment, err := models.IssueCommentFromRecord(did, rkey, record) 877 + if err != nil { 878 + return fmt.Errorf("failed to parse comment from record: %w", err) 838 879 } 839 880 840 - sanitizer := markup.NewSanitizer() 841 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" { 842 - return fmt.Errorf("title is empty after HTML sanitization") 843 - } 844 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 845 - return fmt.Errorf("body is empty after HTML sanitization") 881 + if err := i.Validator.ValidateIssueComment(comment); err != nil { 882 + return fmt.Errorf("failed to validate comment: %w", err) 846 883 } 847 884 848 - err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body) 885 + _, err = db.AddIssueComment(ddb, *comment) 849 886 if err != nil { 850 - l.Error("failed to update issue", "err", err) 851 - return err 887 + return fmt.Errorf("failed to create issue comment: %w", err) 852 888 } 853 889 854 890 return nil 855 891 856 - case models.CommitOperationDelete: 857 - if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil { 858 - l.Error("failed to delete", "err", err) 859 - return fmt.Errorf("failed to delete issue record: %w", err) 892 + case jmodels.CommitOperationDelete: 893 + if err := db.DeleteIssueComments( 894 + ddb, 895 + db.FilterEq("did", did), 896 + db.FilterEq("rkey", rkey), 897 + ); err != nil { 898 + return fmt.Errorf("failed to delete issue comment record: %w", err) 860 899 } 861 900 862 901 return nil 863 902 } 864 903 865 - return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 904 + return nil 866 905 } 867 906 868 - func (i *Ingester) ingestIssueComment(e *models.Event) error { 907 + func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error { 869 908 did := e.Did 870 909 rkey := e.Commit.RKey 871 910 872 911 var err error 873 912 874 - l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 913 + l := i.Logger.With("handler", "ingestLabelDefinition", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 875 914 l.Info("ingesting record") 876 915 877 916 ddb, ok := i.Db.Execer.(*db.DB) 878 917 if !ok { 879 - return fmt.Errorf("failed to index issue comment record, invalid db cast") 918 + return fmt.Errorf("failed to index label definition, invalid db cast") 880 919 } 881 920 882 921 switch e.Commit.Operation { 883 - case models.CommitOperationCreate: 922 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 884 923 raw := json.RawMessage(e.Commit.Record) 885 - record := tangled.RepoIssueComment{} 924 + record := tangled.LabelDefinition{} 886 925 err = json.Unmarshal(raw, &record) 887 926 if err != nil { 888 - l.Error("invalid record", "err", err) 889 - return err 927 + return fmt.Errorf("invalid record: %w", err) 890 928 } 891 929 892 - comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 930 + def, err := models.LabelDefinitionFromRecord(did, rkey, record) 893 931 if err != nil { 894 - l.Error("failed to parse comment from record", "err", err) 895 - return err 932 + return fmt.Errorf("failed to parse labeldef from record: %w", err) 896 933 } 897 934 898 - sanitizer := markup.NewSanitizer() 899 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 900 - return fmt.Errorf("body is empty after HTML sanitization") 935 + if err := i.Validator.ValidateLabelDefinition(def); err != nil { 936 + return fmt.Errorf("failed to validate labeldef: %w", err) 901 937 } 902 938 903 - err = db.NewIssueComment(ddb, &comment) 939 + _, err = db.AddLabelDefinition(ddb, def) 904 940 if err != nil { 905 - l.Error("failed to create issue comment", "err", err) 906 - return err 941 + return fmt.Errorf("failed to create labeldef: %w", err) 907 942 } 908 943 909 944 return nil 910 945 911 - case models.CommitOperationUpdate: 946 + case jmodels.CommitOperationDelete: 947 + if err := db.DeleteLabelDefinition( 948 + ddb, 949 + db.FilterEq("did", did), 950 + db.FilterEq("rkey", rkey), 951 + ); err != nil { 952 + return fmt.Errorf("failed to delete labeldef record: %w", err) 953 + } 954 + 955 + return nil 956 + } 957 + 958 + return nil 959 + } 960 + 961 + func (i *Ingester) ingestLabelOp(e *jmodels.Event) error { 962 + did := e.Did 963 + rkey := e.Commit.RKey 964 + 965 + var err error 966 + 967 + l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 968 + l.Info("ingesting record") 969 + 970 + ddb, ok := i.Db.Execer.(*db.DB) 971 + if !ok { 972 + return fmt.Errorf("failed to index label op, invalid db cast") 973 + } 974 + 975 + switch e.Commit.Operation { 976 + case jmodels.CommitOperationCreate: 912 977 raw := json.RawMessage(e.Commit.Record) 913 - record := tangled.RepoIssueComment{} 978 + record := tangled.LabelOp{} 914 979 err = json.Unmarshal(raw, &record) 915 980 if err != nil { 916 - l.Error("invalid record", "err", err) 917 - return err 981 + return fmt.Errorf("invalid record: %w", err) 918 982 } 919 983 920 - sanitizer := markup.NewSanitizer() 921 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" { 922 - return fmt.Errorf("body is empty after HTML sanitization") 984 + subject := syntax.ATURI(record.Subject) 985 + collection := subject.Collection() 986 + 987 + var repo *models.Repo 988 + switch collection { 989 + case tangled.RepoIssueNSID: 990 + i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) 991 + if err != nil || len(i) != 1 { 992 + return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 993 + } 994 + repo = i[0].Repo 995 + default: 996 + return fmt.Errorf("unsupport label subject: %s", collection) 923 997 } 924 998 925 - err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body) 999 + actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) 1000 + if err != nil { 1001 + return fmt.Errorf("failed to build label application ctx: %w", err) 1002 + } 1003 + 1004 + ops := models.LabelOpsFromRecord(did, rkey, record) 1005 + 1006 + for _, o := range ops { 1007 + def, ok := actx.Defs[o.OperandKey] 1008 + if !ok { 1009 + return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 + } 1011 + if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1012 + return fmt.Errorf("failed to validate labelop: %w", err) 1013 + } 1014 + } 1015 + 1016 + tx, err := ddb.Begin() 926 1017 if err != nil { 927 - l.Error("failed to update issue comment", "err", err) 928 1018 return err 929 1019 } 930 - 931 - return nil 1020 + defer tx.Rollback() 932 1021 933 - case models.CommitOperationDelete: 934 - if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil { 935 - l.Error("failed to delete", "err", err) 936 - return fmt.Errorf("failed to delete issue comment record: %w", err) 1022 + for _, o := range ops { 1023 + _, err = db.AddLabelOp(tx, &o) 1024 + if err != nil { 1025 + return fmt.Errorf("failed to add labelop: %w", err) 1026 + } 937 1027 } 938 1028 939 - return nil 1029 + if err = tx.Commit(); err != nil { 1030 + return err 1031 + } 940 1032 } 941 1033 942 - return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 1034 + return nil 943 1035 }
+534 -294
appview/issues/issues.go
··· 1 1 package issues 2 2 3 3 import ( 4 + "context" 5 + "database/sql" 6 + "errors" 4 7 "fmt" 5 8 "log" 6 - mathrand "math/rand/v2" 9 + "log/slog" 7 10 "net/http" 8 11 "slices" 9 - "strconv" 10 - "strings" 11 12 "time" 12 13 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/atproto/data" 15 + atpclient "github.com/bluesky-social/indigo/atproto/client" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 15 17 lexutil "github.com/bluesky-social/indigo/lex/util" 16 18 "github.com/go-chi/chi/v5" 17 19 18 - "tangled.sh/tangled.sh/core/api/tangled" 19 - "tangled.sh/tangled.sh/core/appview/config" 20 - "tangled.sh/tangled.sh/core/appview/db" 21 - "tangled.sh/tangled.sh/core/appview/notify" 22 - "tangled.sh/tangled.sh/core/appview/oauth" 23 - "tangled.sh/tangled.sh/core/appview/pages" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 - "tangled.sh/tangled.sh/core/appview/pagination" 26 - "tangled.sh/tangled.sh/core/appview/reporesolver" 27 - "tangled.sh/tangled.sh/core/idresolver" 28 - "tangled.sh/tangled.sh/core/tid" 20 + "tangled.org/core/api/tangled" 21 + "tangled.org/core/appview/config" 22 + "tangled.org/core/appview/db" 23 + "tangled.org/core/appview/models" 24 + "tangled.org/core/appview/notify" 25 + "tangled.org/core/appview/oauth" 26 + "tangled.org/core/appview/pages" 27 + "tangled.org/core/appview/pagination" 28 + "tangled.org/core/appview/reporesolver" 29 + "tangled.org/core/appview/validator" 30 + "tangled.org/core/idresolver" 31 + tlog "tangled.org/core/log" 32 + "tangled.org/core/tid" 29 33 ) 30 34 31 35 type Issues struct { ··· 36 40 db *db.DB 37 41 config *config.Config 38 42 notifier notify.Notifier 43 + logger *slog.Logger 44 + validator *validator.Validator 39 45 } 40 46 41 47 func New( ··· 46 52 db *db.DB, 47 53 config *config.Config, 48 54 notifier notify.Notifier, 55 + validator *validator.Validator, 49 56 ) *Issues { 50 57 return &Issues{ 51 58 oauth: oauth, ··· 55 62 db: db, 56 63 config: config, 57 64 notifier: notifier, 65 + logger: tlog.New("issues"), 66 + validator: validator, 58 67 } 59 68 } 60 69 61 70 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 71 + l := rp.logger.With("handler", "RepoSingleIssue") 62 72 user := rp.oauth.GetUser(r) 63 73 f, err := rp.repoResolver.Resolve(r) 64 74 if err != nil { ··· 66 76 return 67 77 } 68 78 69 - issueId := chi.URLParam(r, "issue") 70 - issueIdInt, err := strconv.Atoi(issueId) 71 - if err != nil { 72 - http.Error(w, "bad issue id", http.StatusBadRequest) 73 - log.Println("failed to parse issue id", err) 74 - return 75 - } 76 - 77 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt) 78 - if err != nil { 79 - log.Println("failed to get issue and comments", err) 80 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 79 + issue, ok := r.Context().Value("issue").(*models.Issue) 80 + if !ok { 81 + l.Error("failed to get issue") 82 + rp.pages.Error404(w) 81 83 return 82 84 } 83 85 84 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 86 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 85 87 if err != nil { 86 - log.Println("failed to get issue reactions") 87 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 88 + l.Error("failed to get issue reactions", "err", err) 88 89 } 89 90 90 - userReactions := map[db.ReactionKind]bool{} 91 + userReactions := map[models.ReactionKind]bool{} 91 92 if user != nil { 92 93 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 94 } 94 95 95 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 96 + labelDefs, err := db.GetLabelDefinitions( 97 + rp.db, 98 + db.FilterIn("at_uri", f.Repo.Labels), 99 + db.FilterContains("scope", tangled.RepoIssueNSID), 100 + ) 96 101 if err != nil { 97 - log.Println("failed to resolve issue owner", err) 102 + log.Println("failed to fetch labels", err) 103 + rp.pages.Error503(w) 104 + return 98 105 } 99 106 100 - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 101 - LoggedInUser: user, 102 - RepoInfo: f.RepoInfo(user), 103 - Issue: issue, 104 - Comments: comments, 107 + defs := make(map[string]*models.LabelDefinition) 108 + for _, l := range labelDefs { 109 + defs[l.AtUri().String()] = &l 110 + } 105 111 106 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 107 - 108 - OrderedReactionKinds: db.OrderedReactionKinds, 109 - Reactions: reactionCountMap, 112 + rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 113 + LoggedInUser: user, 114 + RepoInfo: f.RepoInfo(user), 115 + Issue: issue, 116 + CommentList: issue.CommentList(), 117 + OrderedReactionKinds: models.OrderedReactionKinds, 118 + Reactions: reactionMap, 110 119 UserReacted: userReactions, 120 + LabelDefs: defs, 111 121 }) 112 - 113 122 } 114 123 115 - func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 124 + func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 125 + l := rp.logger.With("handler", "EditIssue") 116 126 user := rp.oauth.GetUser(r) 117 127 f, err := rp.repoResolver.Resolve(r) 118 128 if err != nil { ··· 120 130 return 121 131 } 122 132 123 - issueId := chi.URLParam(r, "issue") 124 - issueIdInt, err := strconv.Atoi(issueId) 125 - if err != nil { 126 - http.Error(w, "bad issue id", http.StatusBadRequest) 127 - log.Println("failed to parse issue id", err) 133 + issue, ok := r.Context().Value("issue").(*models.Issue) 134 + if !ok { 135 + l.Error("failed to get issue") 136 + rp.pages.Error404(w) 128 137 return 129 138 } 130 139 131 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 132 - if err != nil { 133 - log.Println("failed to get issue", err) 134 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 135 - return 136 - } 137 - 138 - collaborators, err := f.Collaborators(r.Context()) 139 - if err != nil { 140 - log.Println("failed to fetch repo collaborators: %w", err) 141 - } 142 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 143 - return user.Did == collab.Did 144 - }) 145 - isIssueOwner := user.Did == issue.OwnerDid 140 + switch r.Method { 141 + case http.MethodGet: 142 + rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 143 + LoggedInUser: user, 144 + RepoInfo: f.RepoInfo(user), 145 + Issue: issue, 146 + }) 147 + case http.MethodPost: 148 + noticeId := "issues" 149 + newIssue := issue 150 + newIssue.Title = r.FormValue("title") 151 + newIssue.Body = r.FormValue("body") 146 152 147 - // TODO: make this more granular 148 - if isIssueOwner || isCollaborator { 153 + if err := rp.validator.ValidateIssue(newIssue); err != nil { 154 + l.Error("validation error", "err", err) 155 + rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 156 + return 157 + } 149 158 150 - closed := tangled.RepoIssueStateClosed 159 + newRecord := newIssue.AsRecord() 151 160 161 + // edit an atproto record 152 162 client, err := rp.oauth.AuthorizedClient(r) 153 163 if err != nil { 154 - log.Println("failed to get authorized client", err) 164 + l.Error("failed to get authorized client", "err", err) 165 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 166 + return 167 + } 168 + 169 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 + if err != nil { 171 + l.Error("failed to get record", "err", err) 172 + rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 155 173 return 156 174 } 157 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 158 - Collection: tangled.RepoIssueStateNSID, 175 + 176 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 177 + Collection: tangled.RepoIssueNSID, 159 178 Repo: user.Did, 160 - Rkey: tid.TID(), 179 + Rkey: newIssue.Rkey, 180 + SwapRecord: ex.Cid, 161 181 Record: &lexutil.LexiconTypeDecoder{ 162 - Val: &tangled.RepoIssueState{ 163 - Issue: issue.AtUri().String(), 164 - State: closed, 165 - }, 182 + Val: &newRecord, 166 183 }, 167 184 }) 185 + if err != nil { 186 + l.Error("failed to edit record on PDS", "err", err) 187 + rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 188 + return 189 + } 168 190 191 + // modify on DB -- TODO: transact this cleverly 192 + tx, err := rp.db.Begin() 169 193 if err != nil { 170 - log.Println("failed to update issue state", err) 171 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 194 + l.Error("failed to edit issue on DB", "err", err) 195 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 172 196 return 173 197 } 198 + defer tx.Rollback() 174 199 175 - err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt) 200 + err = db.PutIssue(tx, newIssue) 201 + if err != nil { 202 + log.Println("failed to edit issue", err) 203 + rp.pages.Notice(w, "issues", "Failed to edit issue.") 204 + return 205 + } 206 + 207 + if err = tx.Commit(); err != nil { 208 + l.Error("failed to edit issue", "err", err) 209 + rp.pages.Notice(w, "issues", "Failed to cedit issue.") 210 + return 211 + } 212 + 213 + rp.pages.HxRefresh(w) 214 + } 215 + } 216 + 217 + func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 218 + l := rp.logger.With("handler", "DeleteIssue") 219 + noticeId := "issue-actions-error" 220 + 221 + user := rp.oauth.GetUser(r) 222 + 223 + f, err := rp.repoResolver.Resolve(r) 224 + if err != nil { 225 + l.Error("failed to get repo and knot", "err", err) 226 + return 227 + } 228 + 229 + issue, ok := r.Context().Value("issue").(*models.Issue) 230 + if !ok { 231 + l.Error("failed to get issue") 232 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 233 + return 234 + } 235 + l = l.With("did", issue.Did, "rkey", issue.Rkey) 236 + 237 + // delete from PDS 238 + client, err := rp.oauth.AuthorizedClient(r) 239 + if err != nil { 240 + log.Println("failed to get authorized client", err) 241 + rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 + return 243 + } 244 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 245 + Collection: tangled.RepoIssueNSID, 246 + Repo: issue.Did, 247 + Rkey: issue.Rkey, 248 + }) 249 + if err != nil { 250 + // TODO: transact this better 251 + l.Error("failed to delete issue from PDS", "err", err) 252 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 253 + return 254 + } 255 + 256 + // delete from db 257 + if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 258 + l.Error("failed to delete issue", "err", err) 259 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 260 + return 261 + } 262 + 263 + // return to all issues page 264 + rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 265 + } 266 + 267 + func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 268 + l := rp.logger.With("handler", "CloseIssue") 269 + user := rp.oauth.GetUser(r) 270 + f, err := rp.repoResolver.Resolve(r) 271 + if err != nil { 272 + l.Error("failed to get repo and knot", "err", err) 273 + return 274 + } 275 + 276 + issue, ok := r.Context().Value("issue").(*models.Issue) 277 + if !ok { 278 + l.Error("failed to get issue") 279 + rp.pages.Error404(w) 280 + return 281 + } 282 + 283 + collaborators, err := f.Collaborators(r.Context()) 284 + if err != nil { 285 + log.Println("failed to fetch repo collaborators: %w", err) 286 + } 287 + isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 288 + return user.Did == collab.Did 289 + }) 290 + isIssueOwner := user.Did == issue.Did 291 + 292 + // TODO: make this more granular 293 + if isIssueOwner || isCollaborator { 294 + err = db.CloseIssues( 295 + rp.db, 296 + db.FilterEq("id", issue.Id), 297 + ) 176 298 if err != nil { 177 299 log.Println("failed to close issue", err) 178 300 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 179 301 return 180 302 } 181 303 182 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 304 + // notify about the issue closure 305 + rp.notifier.NewIssueClosed(r.Context(), issue) 306 + 307 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 183 308 return 184 309 } else { 185 310 log.Println("user is not permitted to close issue") ··· 189 314 } 190 315 191 316 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 317 + l := rp.logger.With("handler", "ReopenIssue") 192 318 user := rp.oauth.GetUser(r) 193 319 f, err := rp.repoResolver.Resolve(r) 194 320 if err != nil { ··· 196 322 return 197 323 } 198 324 199 - issueId := chi.URLParam(r, "issue") 200 - issueIdInt, err := strconv.Atoi(issueId) 201 - if err != nil { 202 - http.Error(w, "bad issue id", http.StatusBadRequest) 203 - log.Println("failed to parse issue id", err) 204 - return 205 - } 206 - 207 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 208 - if err != nil { 209 - log.Println("failed to get issue", err) 210 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 325 + issue, ok := r.Context().Value("issue").(*models.Issue) 326 + if !ok { 327 + l.Error("failed to get issue") 328 + rp.pages.Error404(w) 211 329 return 212 330 } 213 331 ··· 218 336 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 219 337 return user.Did == collab.Did 220 338 }) 221 - isIssueOwner := user.Did == issue.OwnerDid 339 + isIssueOwner := user.Did == issue.Did 222 340 223 341 if isCollaborator || isIssueOwner { 224 - err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 342 + err := db.ReopenIssues( 343 + rp.db, 344 + db.FilterEq("id", issue.Id), 345 + ) 225 346 if err != nil { 226 347 log.Println("failed to reopen issue", err) 227 348 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 228 349 return 229 350 } 230 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 351 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 231 352 return 232 353 } else { 233 354 log.Println("user is not the owner of the repo") ··· 237 358 } 238 359 239 360 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 361 + l := rp.logger.With("handler", "NewIssueComment") 240 362 user := rp.oauth.GetUser(r) 241 363 f, err := rp.repoResolver.Resolve(r) 242 364 if err != nil { 243 - log.Println("failed to get repo and knot", err) 365 + l.Error("failed to get repo and knot", "err", err) 244 366 return 245 367 } 246 368 247 - issueId := chi.URLParam(r, "issue") 248 - issueIdInt, err := strconv.Atoi(issueId) 249 - if err != nil { 250 - http.Error(w, "bad issue id", http.StatusBadRequest) 251 - log.Println("failed to parse issue id", err) 369 + issue, ok := r.Context().Value("issue").(*models.Issue) 370 + if !ok { 371 + l.Error("failed to get issue") 372 + rp.pages.Error404(w) 252 373 return 253 374 } 254 375 255 - switch r.Method { 256 - case http.MethodPost: 257 - body := r.FormValue("body") 258 - if body == "" { 259 - rp.pages.Notice(w, "issue", "Body is required") 260 - return 261 - } 376 + body := r.FormValue("body") 377 + if body == "" { 378 + rp.pages.Notice(w, "issue", "Body is required") 379 + return 380 + } 262 381 263 - commentId := mathrand.IntN(1000000) 264 - rkey := tid.TID() 382 + replyToUri := r.FormValue("reply-to") 383 + var replyTo *string 384 + if replyToUri != "" { 385 + replyTo = &replyToUri 386 + } 265 387 266 - err := db.NewIssueComment(rp.db, &db.Comment{ 267 - OwnerDid: user.Did, 268 - RepoAt: f.RepoAt(), 269 - Issue: issueIdInt, 270 - CommentId: commentId, 271 - Body: body, 272 - Rkey: rkey, 273 - }) 274 - if err != nil { 275 - log.Println("failed to create comment", err) 276 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 277 - return 278 - } 388 + comment := models.IssueComment{ 389 + Did: user.Did, 390 + Rkey: tid.TID(), 391 + IssueAt: issue.AtUri().String(), 392 + ReplyTo: replyTo, 393 + Body: body, 394 + Created: time.Now(), 395 + } 396 + if err = rp.validator.ValidateIssueComment(&comment); err != nil { 397 + l.Error("failed to validate comment", "err", err) 398 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 399 + return 400 + } 401 + record := comment.AsRecord() 279 402 280 - createdAt := time.Now().Format(time.RFC3339) 281 - ownerDid := user.Did 282 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 283 - if err != nil { 284 - log.Println("failed to get issue at", err) 285 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 286 - return 287 - } 403 + client, err := rp.oauth.AuthorizedClient(r) 404 + if err != nil { 405 + l.Error("failed to get authorized client", "err", err) 406 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 407 + return 408 + } 288 409 289 - atUri := f.RepoAt().String() 290 - client, err := rp.oauth.AuthorizedClient(r) 291 - if err != nil { 292 - log.Println("failed to get authorized client", err) 293 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 294 - return 295 - } 296 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 297 - Collection: tangled.RepoIssueCommentNSID, 298 - Repo: user.Did, 299 - Rkey: rkey, 300 - Record: &lexutil.LexiconTypeDecoder{ 301 - Val: &tangled.RepoIssueComment{ 302 - Repo: &atUri, 303 - Issue: issueAt, 304 - Owner: &ownerDid, 305 - Body: body, 306 - CreatedAt: createdAt, 307 - }, 308 - }, 309 - }) 310 - if err != nil { 311 - log.Println("failed to create comment", err) 312 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 313 - return 410 + // create a record first 411 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 412 + Collection: tangled.RepoIssueCommentNSID, 413 + Repo: comment.Did, 414 + Rkey: comment.Rkey, 415 + Record: &lexutil.LexiconTypeDecoder{ 416 + Val: &record, 417 + }, 418 + }) 419 + if err != nil { 420 + l.Error("failed to create comment", "err", err) 421 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 422 + return 423 + } 424 + atUri := resp.Uri 425 + defer func() { 426 + if err := rollbackRecord(context.Background(), atUri, client); err != nil { 427 + l.Error("rollback failed", "err", err) 314 428 } 429 + }() 315 430 316 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 431 + commentId, err := db.AddIssueComment(rp.db, comment) 432 + if err != nil { 433 + l.Error("failed to create comment", "err", err) 434 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 317 435 return 318 436 } 437 + 438 + // reset atUri to make rollback a no-op 439 + atUri = "" 440 + 441 + // notify about the new comment 442 + comment.Id = commentId 443 + rp.notifier.NewIssueComment(r.Context(), &comment) 444 + 445 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 319 446 } 320 447 321 448 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 449 + l := rp.logger.With("handler", "IssueComment") 322 450 user := rp.oauth.GetUser(r) 323 451 f, err := rp.repoResolver.Resolve(r) 324 452 if err != nil { 325 - log.Println("failed to get repo and knot", err) 453 + l.Error("failed to get repo and knot", "err", err) 326 454 return 327 455 } 328 456 329 - issueId := chi.URLParam(r, "issue") 330 - issueIdInt, err := strconv.Atoi(issueId) 331 - if err != nil { 332 - http.Error(w, "bad issue id", http.StatusBadRequest) 333 - log.Println("failed to parse issue id", err) 334 - return 335 - } 336 - 337 - commentId := chi.URLParam(r, "comment_id") 338 - commentIdInt, err := strconv.Atoi(commentId) 339 - if err != nil { 340 - http.Error(w, "bad comment id", http.StatusBadRequest) 341 - log.Println("failed to parse issue id", err) 457 + issue, ok := r.Context().Value("issue").(*models.Issue) 458 + if !ok { 459 + l.Error("failed to get issue") 460 + rp.pages.Error404(w) 342 461 return 343 462 } 344 463 345 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 464 + commentId := chi.URLParam(r, "commentId") 465 + comments, err := db.GetIssueComments( 466 + rp.db, 467 + db.FilterEq("id", commentId), 468 + ) 346 469 if err != nil { 347 - log.Println("failed to get issue", err) 348 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 470 + l.Error("failed to fetch comment", "id", commentId) 471 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 349 472 return 350 473 } 351 - 352 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 353 - if err != nil { 354 - http.Error(w, "bad comment id", http.StatusBadRequest) 474 + if len(comments) != 1 { 475 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 476 + http.Error(w, "invalid comment id", http.StatusBadRequest) 355 477 return 356 478 } 479 + comment := comments[0] 357 480 358 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 481 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 359 482 LoggedInUser: user, 360 483 RepoInfo: f.RepoInfo(user), 361 484 Issue: issue, 362 - Comment: comment, 485 + Comment: &comment, 363 486 }) 364 487 } 365 488 366 489 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 490 + l := rp.logger.With("handler", "EditIssueComment") 367 491 user := rp.oauth.GetUser(r) 368 492 f, err := rp.repoResolver.Resolve(r) 369 493 if err != nil { 370 - log.Println("failed to get repo and knot", err) 494 + l.Error("failed to get repo and knot", "err", err) 371 495 return 372 496 } 373 497 374 - issueId := chi.URLParam(r, "issue") 375 - issueIdInt, err := strconv.Atoi(issueId) 376 - if err != nil { 377 - http.Error(w, "bad issue id", http.StatusBadRequest) 378 - log.Println("failed to parse issue id", err) 498 + issue, ok := r.Context().Value("issue").(*models.Issue) 499 + if !ok { 500 + l.Error("failed to get issue") 501 + rp.pages.Error404(w) 379 502 return 380 503 } 381 504 382 - commentId := chi.URLParam(r, "comment_id") 383 - commentIdInt, err := strconv.Atoi(commentId) 505 + commentId := chi.URLParam(r, "commentId") 506 + comments, err := db.GetIssueComments( 507 + rp.db, 508 + db.FilterEq("id", commentId), 509 + ) 384 510 if err != nil { 385 - http.Error(w, "bad comment id", http.StatusBadRequest) 386 - log.Println("failed to parse issue id", err) 511 + l.Error("failed to fetch comment", "id", commentId) 512 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 387 513 return 388 514 } 389 - 390 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 391 - if err != nil { 392 - log.Println("failed to get issue", err) 393 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 515 + if len(comments) != 1 { 516 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 517 + http.Error(w, "invalid comment id", http.StatusBadRequest) 394 518 return 395 519 } 520 + comment := comments[0] 396 521 397 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 398 - if err != nil { 399 - http.Error(w, "bad comment id", http.StatusBadRequest) 400 - return 401 - } 402 - 403 - if comment.OwnerDid != user.Did { 522 + if comment.Did != user.Did { 523 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 404 524 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 405 525 return 406 526 } ··· 411 531 LoggedInUser: user, 412 532 RepoInfo: f.RepoInfo(user), 413 533 Issue: issue, 414 - Comment: comment, 534 + Comment: &comment, 415 535 }) 416 536 case http.MethodPost: 417 537 // extract form value ··· 422 542 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 423 543 return 424 544 } 425 - rkey := comment.Rkey 426 545 427 - // optimistic update 428 - edited := time.Now() 429 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 546 + now := time.Now() 547 + newComment := comment 548 + newComment.Body = newBody 549 + newComment.Edited = &now 550 + record := newComment.AsRecord() 551 + 552 + _, err = db.AddIssueComment(rp.db, newComment) 430 553 if err != nil { 431 554 log.Println("failed to perferom update-description query", err) 432 555 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 434 557 } 435 558 436 559 // rkey is optional, it was introduced later 437 - if comment.Rkey != "" { 560 + if newComment.Rkey != "" { 438 561 // update the record on pds 439 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 562 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 440 563 if err != nil { 441 - // failed to get record 442 - log.Println(err, rkey) 564 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 443 565 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 444 566 return 445 567 } 446 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 447 - record, _ := data.UnmarshalJSON(value) 448 568 449 - repoAt := record["repo"].(string) 450 - issueAt := record["issue"].(string) 451 - createdAt := record["createdAt"].(string) 452 - 453 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 569 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 454 570 Collection: tangled.RepoIssueCommentNSID, 455 571 Repo: user.Did, 456 - Rkey: rkey, 572 + Rkey: newComment.Rkey, 457 573 SwapRecord: ex.Cid, 458 574 Record: &lexutil.LexiconTypeDecoder{ 459 - Val: &tangled.RepoIssueComment{ 460 - Repo: &repoAt, 461 - Issue: issueAt, 462 - Owner: &comment.OwnerDid, 463 - Body: newBody, 464 - CreatedAt: createdAt, 465 - }, 575 + Val: &record, 466 576 }, 467 577 }) 468 578 if err != nil { 469 - log.Println(err) 579 + l.Error("failed to update record on PDS", "err", err) 470 580 } 471 581 } 472 582 473 - // optimistic update for htmx 474 - comment.Body = newBody 475 - comment.Edited = &edited 476 - 477 583 // return new comment body with htmx 478 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 584 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 479 585 LoggedInUser: user, 480 586 RepoInfo: f.RepoInfo(user), 481 587 Issue: issue, 482 - Comment: comment, 588 + Comment: &newComment, 483 589 }) 590 + } 591 + } 592 + 593 + func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 594 + l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 595 + user := rp.oauth.GetUser(r) 596 + f, err := rp.repoResolver.Resolve(r) 597 + if err != nil { 598 + l.Error("failed to get repo and knot", "err", err) 484 599 return 600 + } 485 601 602 + issue, ok := r.Context().Value("issue").(*models.Issue) 603 + if !ok { 604 + l.Error("failed to get issue") 605 + rp.pages.Error404(w) 606 + return 486 607 } 487 608 609 + commentId := chi.URLParam(r, "commentId") 610 + comments, err := db.GetIssueComments( 611 + rp.db, 612 + db.FilterEq("id", commentId), 613 + ) 614 + if err != nil { 615 + l.Error("failed to fetch comment", "id", commentId) 616 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 617 + return 618 + } 619 + if len(comments) != 1 { 620 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 621 + http.Error(w, "invalid comment id", http.StatusBadRequest) 622 + return 623 + } 624 + comment := comments[0] 625 + 626 + rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 627 + LoggedInUser: user, 628 + RepoInfo: f.RepoInfo(user), 629 + Issue: issue, 630 + Comment: &comment, 631 + }) 488 632 } 489 633 490 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 634 + func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 635 + l := rp.logger.With("handler", "ReplyIssueComment") 491 636 user := rp.oauth.GetUser(r) 492 637 f, err := rp.repoResolver.Resolve(r) 493 638 if err != nil { 494 - log.Println("failed to get repo and knot", err) 639 + l.Error("failed to get repo and knot", "err", err) 495 640 return 496 641 } 497 642 498 - issueId := chi.URLParam(r, "issue") 499 - issueIdInt, err := strconv.Atoi(issueId) 500 - if err != nil { 501 - http.Error(w, "bad issue id", http.StatusBadRequest) 502 - log.Println("failed to parse issue id", err) 643 + issue, ok := r.Context().Value("issue").(*models.Issue) 644 + if !ok { 645 + l.Error("failed to get issue") 646 + rp.pages.Error404(w) 503 647 return 504 648 } 505 649 506 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 650 + commentId := chi.URLParam(r, "commentId") 651 + comments, err := db.GetIssueComments( 652 + rp.db, 653 + db.FilterEq("id", commentId), 654 + ) 507 655 if err != nil { 508 - log.Println("failed to get issue", err) 509 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 656 + l.Error("failed to fetch comment", "id", commentId) 657 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 510 658 return 511 659 } 660 + if len(comments) != 1 { 661 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 662 + http.Error(w, "invalid comment id", http.StatusBadRequest) 663 + return 664 + } 665 + comment := comments[0] 512 666 513 - commentId := chi.URLParam(r, "comment_id") 514 - commentIdInt, err := strconv.Atoi(commentId) 667 + rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 668 + LoggedInUser: user, 669 + RepoInfo: f.RepoInfo(user), 670 + Issue: issue, 671 + Comment: &comment, 672 + }) 673 + } 674 + 675 + func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 676 + l := rp.logger.With("handler", "DeleteIssueComment") 677 + user := rp.oauth.GetUser(r) 678 + f, err := rp.repoResolver.Resolve(r) 515 679 if err != nil { 516 - http.Error(w, "bad comment id", http.StatusBadRequest) 517 - log.Println("failed to parse issue id", err) 680 + l.Error("failed to get repo and knot", "err", err) 518 681 return 519 682 } 520 683 521 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 684 + issue, ok := r.Context().Value("issue").(*models.Issue) 685 + if !ok { 686 + l.Error("failed to get issue") 687 + rp.pages.Error404(w) 688 + return 689 + } 690 + 691 + commentId := chi.URLParam(r, "commentId") 692 + comments, err := db.GetIssueComments( 693 + rp.db, 694 + db.FilterEq("id", commentId), 695 + ) 522 696 if err != nil { 523 - http.Error(w, "bad comment id", http.StatusBadRequest) 697 + l.Error("failed to fetch comment", "id", commentId) 698 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 524 699 return 525 700 } 701 + if len(comments) != 1 { 702 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 703 + http.Error(w, "invalid comment id", http.StatusBadRequest) 704 + return 705 + } 706 + comment := comments[0] 526 707 527 - if comment.OwnerDid != user.Did { 708 + if comment.Did != user.Did { 709 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 528 710 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 529 711 return 530 712 } ··· 536 718 537 719 // optimistic deletion 538 720 deleted := time.Now() 539 - err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 721 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 540 722 if err != nil { 541 - log.Println("failed to delete comment") 723 + l.Error("failed to delete comment", "err", err) 542 724 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 543 725 return 544 726 } ··· 551 733 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 552 734 return 553 735 } 554 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 555 - Collection: tangled.GraphFollowNSID, 736 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 737 + Collection: tangled.RepoIssueCommentNSID, 556 738 Repo: user.Did, 557 739 Rkey: comment.Rkey, 558 740 }) ··· 566 748 comment.Deleted = &deleted 567 749 568 750 // htmx fragment of comment after deletion 569 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 751 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 570 752 LoggedInUser: user, 571 753 RepoInfo: f.RepoInfo(user), 572 754 Issue: issue, 573 - Comment: comment, 755 + Comment: &comment, 574 756 }) 575 757 } 576 758 ··· 600 782 return 601 783 } 602 784 603 - issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 785 + openVal := 0 786 + if isOpen { 787 + openVal = 1 788 + } 789 + issues, err := db.GetIssuesPaginated( 790 + rp.db, 791 + page, 792 + db.FilterEq("repo_at", f.RepoAt()), 793 + db.FilterEq("open", openVal), 794 + ) 604 795 if err != nil { 605 796 log.Println("failed to get issues", err) 606 797 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 607 798 return 608 799 } 609 800 801 + labelDefs, err := db.GetLabelDefinitions( 802 + rp.db, 803 + db.FilterIn("at_uri", f.Repo.Labels), 804 + db.FilterContains("scope", tangled.RepoIssueNSID), 805 + ) 806 + if err != nil { 807 + log.Println("failed to fetch labels", err) 808 + rp.pages.Error503(w) 809 + return 810 + } 811 + 812 + defs := make(map[string]*models.LabelDefinition) 813 + for _, l := range labelDefs { 814 + defs[l.AtUri().String()] = &l 815 + } 816 + 610 817 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 611 818 LoggedInUser: rp.oauth.GetUser(r), 612 819 RepoInfo: f.RepoInfo(user), 613 820 Issues: issues, 821 + LabelDefs: defs, 614 822 FilteringByOpen: isOpen, 615 823 Page: page, 616 824 }) 617 825 } 618 826 619 827 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 828 + l := rp.logger.With("handler", "NewIssue") 620 829 user := rp.oauth.GetUser(r) 621 830 622 831 f, err := rp.repoResolver.Resolve(r) 623 832 if err != nil { 624 - log.Println("failed to get repo and knot", err) 833 + l.Error("failed to get repo and knot", "err", err) 625 834 return 626 835 } 627 836 ··· 632 841 RepoInfo: f.RepoInfo(user), 633 842 }) 634 843 case http.MethodPost: 635 - title := r.FormValue("title") 636 - body := r.FormValue("body") 844 + issue := &models.Issue{ 845 + RepoAt: f.RepoAt(), 846 + Rkey: tid.TID(), 847 + Title: r.FormValue("title"), 848 + Body: r.FormValue("body"), 849 + Did: user.Did, 850 + Created: time.Now(), 851 + } 637 852 638 - if title == "" || body == "" { 639 - rp.pages.Notice(w, "issues", "Title and body are required") 853 + if err := rp.validator.ValidateIssue(issue); err != nil { 854 + l.Error("validation error", "err", err) 855 + rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 640 856 return 641 857 } 642 858 643 - sanitizer := markup.NewSanitizer() 644 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 645 - rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 859 + record := issue.AsRecord() 860 + 861 + // create an atproto record 862 + client, err := rp.oauth.AuthorizedClient(r) 863 + if err != nil { 864 + l.Error("failed to get authorized client", "err", err) 865 + rp.pages.Notice(w, "issues", "Failed to create issue.") 646 866 return 647 867 } 648 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 649 - rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 868 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 869 + Collection: tangled.RepoIssueNSID, 870 + Repo: user.Did, 871 + Rkey: issue.Rkey, 872 + Record: &lexutil.LexiconTypeDecoder{ 873 + Val: &record, 874 + }, 875 + }) 876 + if err != nil { 877 + l.Error("failed to create issue", "err", err) 878 + rp.pages.Notice(w, "issues", "Failed to create issue.") 650 879 return 651 880 } 881 + atUri := resp.Uri 652 882 653 883 tx, err := rp.db.BeginTx(r.Context(), nil) 654 884 if err != nil { 655 885 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 656 886 return 657 887 } 888 + rollback := func() { 889 + err1 := tx.Rollback() 890 + err2 := rollbackRecord(context.Background(), atUri, client) 658 891 659 - issue := &db.Issue{ 660 - RepoAt: f.RepoAt(), 661 - Rkey: tid.TID(), 662 - Title: title, 663 - Body: body, 664 - OwnerDid: user.Did, 892 + if errors.Is(err1, sql.ErrTxDone) { 893 + err1 = nil 894 + } 895 + 896 + if err := errors.Join(err1, err2); err != nil { 897 + l.Error("failed to rollback txn", "err", err) 898 + } 665 899 } 666 - err = db.NewIssue(tx, issue) 900 + defer rollback() 901 + 902 + err = db.PutIssue(tx, issue) 667 903 if err != nil { 668 904 log.Println("failed to create issue", err) 669 905 rp.pages.Notice(w, "issues", "Failed to create issue.") 670 906 return 671 907 } 672 908 673 - client, err := rp.oauth.AuthorizedClient(r) 674 - if err != nil { 675 - log.Println("failed to get authorized client", err) 676 - rp.pages.Notice(w, "issues", "Failed to create issue.") 677 - return 678 - } 679 - atUri := f.RepoAt().String() 680 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 681 - Collection: tangled.RepoIssueNSID, 682 - Repo: user.Did, 683 - Rkey: issue.Rkey, 684 - Record: &lexutil.LexiconTypeDecoder{ 685 - Val: &tangled.RepoIssue{ 686 - Repo: atUri, 687 - Title: title, 688 - Body: &body, 689 - }, 690 - }, 691 - }) 692 - if err != nil { 909 + if err = tx.Commit(); err != nil { 693 910 log.Println("failed to create issue", err) 694 911 rp.pages.Notice(w, "issues", "Failed to create issue.") 695 912 return 696 913 } 697 914 915 + // everything is successful, do not rollback the atproto record 916 + atUri = "" 698 917 rp.notifier.NewIssue(r.Context(), issue) 699 - 700 918 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 701 919 return 702 920 } 703 921 } 922 + 923 + // this is used to rollback changes made to the PDS 924 + // 925 + // it is a no-op if the provided ATURI is empty 926 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 927 + if aturi == "" { 928 + return nil 929 + } 930 + 931 + parsed := syntax.ATURI(aturi) 932 + 933 + collection := parsed.Collection().String() 934 + repo := parsed.Authority().String() 935 + rkey := parsed.RecordKey().String() 936 + 937 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 938 + Collection: collection, 939 + Repo: repo, 940 + Rkey: rkey, 941 + }) 942 + return err 943 + }
+25 -11
appview/issues/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (i *Issues) Router(mw *middleware.Middleware) http.Handler { ··· 12 12 13 13 r.Route("/", func(r chi.Router) { 14 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 - r.Get("/{issue}", i.RepoSingleIssue) 15 + 16 + r.Route("/{issue}", func(r chi.Router) { 17 + r.Use(mw.ResolveIssue) 18 + r.Get("/", i.RepoSingleIssue) 19 + 20 + // authenticated routes 21 + r.Group(func(r chi.Router) { 22 + r.Use(middleware.AuthMiddleware(i.oauth)) 23 + r.Post("/comment", i.NewIssueComment) 24 + r.Route("/comment/{commentId}/", func(r chi.Router) { 25 + r.Get("/", i.IssueComment) 26 + r.Delete("/", i.DeleteIssueComment) 27 + r.Get("/edit", i.EditIssueComment) 28 + r.Post("/edit", i.EditIssueComment) 29 + r.Get("/reply", i.ReplyIssueComment) 30 + r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 31 + }) 32 + r.Get("/edit", i.EditIssue) 33 + r.Post("/edit", i.EditIssue) 34 + r.Delete("/", i.DeleteIssue) 35 + r.Post("/close", i.CloseIssue) 36 + r.Post("/reopen", i.ReopenIssue) 37 + }) 38 + }) 16 39 17 40 r.Group(func(r chi.Router) { 18 41 r.Use(middleware.AuthMiddleware(i.oauth)) 19 42 r.Get("/new", i.NewIssue) 20 43 r.Post("/new", i.NewIssue) 21 - r.Post("/{issue}/comment", i.NewIssueComment) 22 - r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 23 - r.Get("/", i.IssueComment) 24 - r.Delete("/", i.DeleteIssueComment) 25 - r.Get("/edit", i.EditIssueComment) 26 - r.Post("/edit", i.EditIssueComment) 27 - }) 28 - r.Post("/{issue}/close", i.CloseIssue) 29 - r.Post("/{issue}/reopen", i.ReopenIssue) 30 44 }) 31 45 }) 32 46
+24 -52
appview/knots/knots.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 - "log" 7 6 "log/slog" 8 7 "net/http" 9 8 "slices" 10 9 "time" 11 10 12 11 "github.com/go-chi/chi/v5" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/appview/config" 15 - "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/middleware" 17 - "tangled.sh/tangled.sh/core/appview/oauth" 18 - "tangled.sh/tangled.sh/core/appview/pages" 19 - "tangled.sh/tangled.sh/core/appview/serververify" 20 - "tangled.sh/tangled.sh/core/eventconsumer" 21 - "tangled.sh/tangled.sh/core/idresolver" 22 - "tangled.sh/tangled.sh/core/rbac" 23 - "tangled.sh/tangled.sh/core/tid" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/config" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/middleware" 16 + "tangled.org/core/appview/models" 17 + "tangled.org/core/appview/oauth" 18 + "tangled.org/core/appview/pages" 19 + "tangled.org/core/appview/serververify" 20 + "tangled.org/core/appview/xrpcclient" 21 + "tangled.org/core/eventconsumer" 22 + "tangled.org/core/idresolver" 23 + "tangled.org/core/rbac" 24 + "tangled.org/core/tid" 24 25 25 26 comatproto "github.com/bluesky-social/indigo/api/atproto" 26 27 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 49 50 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 51 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 52 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 52 - 53 - r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 54 53 55 54 return r 56 55 } ··· 121 120 } 122 121 123 122 // organize repos by did 124 - repoMap := make(map[string][]db.Repo) 123 + repoMap := make(map[string][]models.Repo) 125 124 for _, r := range repos { 126 125 repoMap[r.Did] = append(repoMap[r.Did], r) 127 126 } ··· 186 185 return 187 186 } 188 187 189 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 190 189 var exCid *string 191 190 if ex != nil { 192 191 exCid = ex.Cid 193 192 } 194 193 195 194 // re-announce by registering under same rkey 196 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 197 196 Collection: tangled.KnotNSID, 198 197 Repo: user.Did, 199 198 Rkey: domain, ··· 324 323 return 325 324 } 326 325 327 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 328 327 Collection: tangled.KnotNSID, 329 328 Repo: user.Did, 330 329 Rkey: domain, ··· 399 398 if err != nil { 400 399 l.Error("verification failed", "err", err) 401 400 402 - if errors.Is(err, serververify.FetchError) { 403 - k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 401 + if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 402 + k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!") 404 403 return 405 404 } 406 405 ··· 420 419 return 421 420 } 422 421 423 - // if this knot was previously read-only, then emit a record too 422 + // if this knot requires upgrade, then emit a record too 424 423 // 425 424 // this is part of migrating from the old knot system to the new one 426 - if registration.ReadOnly { 425 + if registration.NeedsUpgrade { 427 426 // re-announce by registering under same rkey 428 427 client, err := k.OAuth.AuthorizedClient(r) 429 428 if err != nil { ··· 432 431 return 433 432 } 434 433 435 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 436 435 var exCid *string 437 436 if ex != nil { 438 437 exCid = ex.Cid 439 438 } 440 439 441 440 // ignore the error here 442 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 443 442 Collection: tangled.KnotNSID, 444 443 Repo: user.Did, 445 444 Rkey: domain, ··· 485 484 } 486 485 updatedRegistration := registrations[0] 487 486 488 - log.Println(updatedRegistration) 489 - 490 487 w.Header().Set("HX-Reswap", "outerHTML") 491 488 k.Pages.KnotListing(w, pages.KnotListingParams{ 492 489 Registration: &updatedRegistration, ··· 558 555 559 556 rkey := tid.TID() 560 557 561 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 562 559 Collection: tangled.KnotMemberNSID, 563 560 Repo: user.Did, 564 561 Rkey: rkey, ··· 678 675 // ok 679 676 k.Pages.HxRefresh(w) 680 677 } 681 - 682 - func (k *Knots) banner(w http.ResponseWriter, r *http.Request) { 683 - user := k.OAuth.GetUser(r) 684 - l := k.Logger.With("handler", "removeMember") 685 - l = l.With("did", user.Did) 686 - l = l.With("handle", user.Handle) 687 - 688 - registrations, err := db.GetRegistrations( 689 - k.Db, 690 - db.FilterEq("did", user.Did), 691 - db.FilterEq("read_only", 1), 692 - ) 693 - if err != nil { 694 - l.Error("non-fatal: failed to get registrations") 695 - return 696 - } 697 - 698 - if registrations == nil { 699 - return 700 - } 701 - 702 - k.Pages.KnotBanner(w, pages.KnotBannerParams{ 703 - Registrations: registrations, 704 - }) 705 - }
+272
appview/labels/labels.go
··· 1 + package labels 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + "time" 11 + 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/middleware" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/oauth" 17 + "tangled.org/core/appview/pages" 18 + "tangled.org/core/appview/validator" 19 + "tangled.org/core/log" 20 + "tangled.org/core/rbac" 21 + "tangled.org/core/tid" 22 + 23 + comatproto "github.com/bluesky-social/indigo/api/atproto" 24 + atpclient "github.com/bluesky-social/indigo/atproto/client" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 26 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 + "github.com/go-chi/chi/v5" 28 + ) 29 + 30 + type Labels struct { 31 + oauth *oauth.OAuth 32 + pages *pages.Pages 33 + db *db.DB 34 + logger *slog.Logger 35 + validator *validator.Validator 36 + enforcer *rbac.Enforcer 37 + } 38 + 39 + func New( 40 + oauth *oauth.OAuth, 41 + pages *pages.Pages, 42 + db *db.DB, 43 + validator *validator.Validator, 44 + enforcer *rbac.Enforcer, 45 + ) *Labels { 46 + logger := log.New("labels") 47 + 48 + return &Labels{ 49 + oauth: oauth, 50 + pages: pages, 51 + db: db, 52 + logger: logger, 53 + validator: validator, 54 + enforcer: enforcer, 55 + } 56 + } 57 + 58 + func (l *Labels) Router(mw *middleware.Middleware) http.Handler { 59 + r := chi.NewRouter() 60 + 61 + r.Use(middleware.AuthMiddleware(l.oauth)) 62 + r.Put("/perform", l.PerformLabelOp) 63 + 64 + return r 65 + } 66 + 67 + // this is a tricky handler implementation: 68 + // - the user selects the new state of all the labels in the label panel and hits save 69 + // - this handler should calculate the diff in order to create the labelop record 70 + // - we need the diff in order to maintain a "history" of operations performed by users 71 + func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { 72 + user := l.oauth.GetUser(r) 73 + 74 + noticeId := "add-label-error" 75 + 76 + fail := func(msg string, err error) { 77 + l.logger.Error("failed to add label", "err", err) 78 + l.pages.Notice(w, noticeId, msg) 79 + } 80 + 81 + if err := r.ParseForm(); err != nil { 82 + fail("Invalid form.", err) 83 + return 84 + } 85 + 86 + did := user.Did 87 + rkey := tid.TID() 88 + performedAt := time.Now() 89 + indexedAt := time.Now() 90 + repoAt := r.Form.Get("repo") 91 + subjectUri := r.Form.Get("subject") 92 + 93 + repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt)) 94 + if err != nil { 95 + fail("Failed to get repository.", err) 96 + return 97 + } 98 + 99 + // find all the labels that this repo subscribes to 100 + repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 101 + if err != nil { 102 + fail("Failed to get labels for this repository.", err) 103 + return 104 + } 105 + 106 + var labelAts []string 107 + for _, rl := range repoLabels { 108 + labelAts = append(labelAts, rl.LabelAt.String()) 109 + } 110 + 111 + actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts)) 112 + if err != nil { 113 + fail("Invalid form data.", err) 114 + return 115 + } 116 + 117 + // calculate the start state by applying already known labels 118 + existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 119 + if err != nil { 120 + fail("Invalid form data.", err) 121 + return 122 + } 123 + 124 + labelState := models.NewLabelState() 125 + actx.ApplyLabelOps(labelState, existingOps) 126 + 127 + var labelOps []models.LabelOp 128 + 129 + // first delete all existing state 130 + for key, vals := range labelState.Inner() { 131 + for val := range vals { 132 + labelOps = append(labelOps, models.LabelOp{ 133 + Did: did, 134 + Rkey: rkey, 135 + Subject: syntax.ATURI(subjectUri), 136 + Operation: models.LabelOperationDel, 137 + OperandKey: key, 138 + OperandValue: val, 139 + PerformedAt: performedAt, 140 + IndexedAt: indexedAt, 141 + }) 142 + } 143 + } 144 + 145 + // add all the new state the user specified 146 + for key, vals := range r.Form { 147 + if _, ok := actx.Defs[key]; !ok { 148 + continue 149 + } 150 + 151 + for _, val := range vals { 152 + labelOps = append(labelOps, models.LabelOp{ 153 + Did: did, 154 + Rkey: rkey, 155 + Subject: syntax.ATURI(subjectUri), 156 + Operation: models.LabelOperationAdd, 157 + OperandKey: key, 158 + OperandValue: val, 159 + PerformedAt: performedAt, 160 + IndexedAt: indexedAt, 161 + }) 162 + } 163 + } 164 + 165 + for i := range labelOps { 166 + def := actx.Defs[labelOps[i].OperandKey] 167 + if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 168 + fail(fmt.Sprintf("Invalid form data: %s", err), err) 169 + return 170 + } 171 + } 172 + 173 + // reduce the opset 174 + labelOps = models.ReduceLabelOps(labelOps) 175 + 176 + // next, apply all ops introduced in this request and filter out ones that are no-ops 177 + validLabelOps := labelOps[:0] 178 + for _, op := range labelOps { 179 + if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError { 180 + validLabelOps = append(validLabelOps, op) 181 + } 182 + } 183 + 184 + // nothing to do 185 + if len(validLabelOps) == 0 { 186 + l.pages.HxRefresh(w) 187 + return 188 + } 189 + 190 + // create an atproto record of valid ops 191 + record := models.LabelOpsAsRecord(validLabelOps) 192 + 193 + client, err := l.oauth.AuthorizedClient(r) 194 + if err != nil { 195 + fail("Failed to authorize user.", err) 196 + return 197 + } 198 + 199 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 + Collection: tangled.LabelOpNSID, 201 + Repo: did, 202 + Rkey: rkey, 203 + Record: &lexutil.LexiconTypeDecoder{ 204 + Val: &record, 205 + }, 206 + }) 207 + if err != nil { 208 + fail("Failed to create record on PDS for user.", err) 209 + return 210 + } 211 + atUri := resp.Uri 212 + 213 + tx, err := l.db.BeginTx(r.Context(), nil) 214 + if err != nil { 215 + fail("Failed to update labels. Try again later.", err) 216 + return 217 + } 218 + 219 + rollback := func() { 220 + err1 := tx.Rollback() 221 + err2 := rollbackRecord(context.Background(), atUri, client) 222 + 223 + // ignore txn complete errors, this is okay 224 + if errors.Is(err1, sql.ErrTxDone) { 225 + err1 = nil 226 + } 227 + 228 + if errs := errors.Join(err1, err2); errs != nil { 229 + return 230 + } 231 + } 232 + defer rollback() 233 + 234 + for _, o := range validLabelOps { 235 + if _, err := db.AddLabelOp(l.db, &o); err != nil { 236 + fail("Failed to update labels. Try again later.", err) 237 + return 238 + } 239 + } 240 + 241 + err = tx.Commit() 242 + if err != nil { 243 + return 244 + } 245 + 246 + // clear aturi when everything is successful 247 + atUri = "" 248 + 249 + l.pages.HxRefresh(w) 250 + } 251 + 252 + // this is used to rollback changes made to the PDS 253 + // 254 + // it is a no-op if the provided ATURI is empty 255 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 256 + if aturi == "" { 257 + return nil 258 + } 259 + 260 + parsed := syntax.ATURI(aturi) 261 + 262 + collection := parsed.Collection().String() 263 + repo := parsed.Authority().String() 264 + rkey := parsed.RecordKey().String() 265 + 266 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 267 + Collection: collection, 268 + Repo: repo, 269 + Rkey: rkey, 270 + }) 271 + return err 272 + }
+64 -18
appview/middleware/middleware.go
··· 12 12 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/go-chi/chi/v5" 15 - "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/oauth" 17 - "tangled.sh/tangled.sh/core/appview/pages" 18 - "tangled.sh/tangled.sh/core/appview/pagination" 19 - "tangled.sh/tangled.sh/core/appview/reporesolver" 20 - "tangled.sh/tangled.sh/core/idresolver" 21 - "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/oauth" 17 + "tangled.org/core/appview/pages" 18 + "tangled.org/core/appview/pagination" 19 + "tangled.org/core/appview/reporesolver" 20 + "tangled.org/core/idresolver" 21 + "tangled.org/core/rbac" 22 22 ) 23 23 24 24 type Middleware struct { ··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 49 returnURL := "/" ··· 63 63 } 64 64 } 65 65 66 - _, auth, err := a.GetSession(r) 66 + sess, err := o.ResumeSession(r) 67 67 if err != nil { 68 - log.Println("not logged in, redirecting", "err", err) 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 69 redirectFunc(w, r) 70 70 return 71 71 } 72 72 73 - if !auth { 74 - log.Printf("not logged in, redirecting") 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 75 redirectFunc(w, r) 76 76 return 77 77 } ··· 213 213 return 214 214 } 215 215 216 - repo, err := db.GetRepo(mw.db, id.DID.String(), repoName) 216 + repo, err := db.GetRepo( 217 + mw.db, 218 + db.FilterEq("did", id.DID.String()), 219 + db.FilterEq("name", repoName), 220 + ) 217 221 if err != nil { 218 - // invalid did or handle 219 - log.Println("failed to resolve repo") 222 + log.Println("failed to resolve repo", "err", err) 220 223 mw.pages.ErrorKnot404(w) 221 224 return 222 225 } ··· 275 278 } 276 279 } 277 280 281 + // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 282 + func (mw Middleware) ResolveIssue(next http.Handler) http.Handler { 283 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 284 + f, err := mw.repoResolver.Resolve(r) 285 + if err != nil { 286 + log.Println("failed to fully resolve repo", err) 287 + mw.pages.ErrorKnot404(w) 288 + return 289 + } 290 + 291 + issueIdStr := chi.URLParam(r, "issue") 292 + issueId, err := strconv.Atoi(issueIdStr) 293 + if err != nil { 294 + log.Println("failed to fully resolve issue ID", err) 295 + mw.pages.ErrorKnot404(w) 296 + return 297 + } 298 + 299 + issues, err := db.GetIssues( 300 + mw.db, 301 + db.FilterEq("repo_at", f.RepoAt()), 302 + db.FilterEq("issue_id", issueId), 303 + ) 304 + if err != nil { 305 + log.Println("failed to get issues", "err", err) 306 + return 307 + } 308 + if len(issues) != 1 { 309 + log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 310 + return 311 + } 312 + issue := issues[0] 313 + 314 + ctx := context.WithValue(r.Context(), "issue", &issue) 315 + next.ServeHTTP(w, r.WithContext(ctx)) 316 + }) 317 + } 318 + 278 319 // this should serve the go-import meta tag even if the path is technically 279 320 // a 404 like tangled.sh/oppi.li/go-git/v5 321 + // 322 + // we're keeping the tangled.sh go-import tag too to maintain backward 323 + // compatiblity for modules that still point there. they will be redirected 324 + // to fetch source from tangled.org 280 325 func (mw Middleware) GoImport() middlewareFunc { 281 326 return func(next http.Handler) http.Handler { 282 327 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ··· 292 337 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 293 338 if r.URL.Query().Get("go-get") == "1" { 294 339 html := fmt.Sprintf( 295 - `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, 296 - fullName, 297 - fullName, 340 + `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/> 341 + <meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`, 342 + fullName, fullName, 343 + fullName, fullName, 298 344 ) 299 345 w.Header().Set("Content-Type", "text/html") 300 346 w.Write([]byte(html))
+30
appview/models/artifact.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/ipfs/go-cid" 10 + "tangled.org/core/api/tangled" 11 + ) 12 + 13 + type Artifact struct { 14 + Id uint64 15 + Did string 16 + Rkey string 17 + 18 + RepoAt syntax.ATURI 19 + Tag plumbing.Hash 20 + CreatedAt time.Time 21 + 22 + BlobCid cid.Cid 23 + Name string 24 + Size uint64 25 + MimeType string 26 + } 27 + 28 + func (a *Artifact) ArtifactAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey)) 30 + }
+21
appview/models/collaborator.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Collaborator struct { 10 + // identifiers for the record 11 + Id int64 12 + Did syntax.DID 13 + Rkey string 14 + 15 + // content 16 + SubjectDid syntax.DID 17 + RepoAt syntax.ATURI 18 + 19 + // meta 20 + Created time.Time 21 + }
+16
appview/models/email.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Email struct { 8 + ID int64 9 + Did string 10 + Address string 11 + Verified bool 12 + Primary bool 13 + VerificationCode string 14 + LastSent *time.Time 15 + CreatedAt time.Time 16 + }
+38
appview/models/follow.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Follow struct { 8 + UserDid string 9 + SubjectDid string 10 + FollowedAt time.Time 11 + Rkey string 12 + } 13 + 14 + type FollowStats struct { 15 + Followers int64 16 + Following int64 17 + } 18 + 19 + type FollowStatus int 20 + 21 + const ( 22 + IsNotFollowing FollowStatus = iota 23 + IsFollowing 24 + IsSelf 25 + ) 26 + 27 + func (s FollowStatus) String() string { 28 + switch s { 29 + case IsNotFollowing: 30 + return "IsNotFollowing" 31 + case IsFollowing: 32 + return "IsFollowing" 33 + case IsSelf: 34 + return "IsSelf" 35 + default: 36 + return "IsNotFollowing" 37 + } 38 + }
+194
appview/models/issue.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "sort" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Issue struct { 13 + Id int64 14 + Did string 15 + Rkey string 16 + RepoAt syntax.ATURI 17 + IssueId int 18 + Created time.Time 19 + Edited *time.Time 20 + Deleted *time.Time 21 + Title string 22 + Body string 23 + Open bool 24 + 25 + // optionally, populate this when querying for reverse mappings 26 + // like comment counts, parent repo etc. 27 + Comments []IssueComment 28 + Labels LabelState 29 + Repo *Repo 30 + } 31 + 32 + func (i *Issue) AtUri() syntax.ATURI { 33 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 34 + } 35 + 36 + func (i *Issue) AsRecord() tangled.RepoIssue { 37 + return tangled.RepoIssue{ 38 + Repo: i.RepoAt.String(), 39 + Title: i.Title, 40 + Body: &i.Body, 41 + CreatedAt: i.Created.Format(time.RFC3339), 42 + } 43 + } 44 + 45 + func (i *Issue) State() string { 46 + if i.Open { 47 + return "open" 48 + } 49 + return "closed" 50 + } 51 + 52 + type CommentListItem struct { 53 + Self *IssueComment 54 + Replies []*IssueComment 55 + } 56 + 57 + func (i *Issue) CommentList() []CommentListItem { 58 + // Create a map to quickly find comments by their aturi 59 + toplevel := make(map[string]*CommentListItem) 60 + var replies []*IssueComment 61 + 62 + // collect top level comments into the map 63 + for _, comment := range i.Comments { 64 + if comment.IsTopLevel() { 65 + toplevel[comment.AtUri().String()] = &CommentListItem{ 66 + Self: &comment, 67 + } 68 + } else { 69 + replies = append(replies, &comment) 70 + } 71 + } 72 + 73 + for _, r := range replies { 74 + parentAt := *r.ReplyTo 75 + if parent, exists := toplevel[parentAt]; exists { 76 + parent.Replies = append(parent.Replies, r) 77 + } 78 + } 79 + 80 + var listing []CommentListItem 81 + for _, v := range toplevel { 82 + listing = append(listing, *v) 83 + } 84 + 85 + // sort everything 86 + sortFunc := func(a, b *IssueComment) bool { 87 + return a.Created.Before(b.Created) 88 + } 89 + sort.Slice(listing, func(i, j int) bool { 90 + return sortFunc(listing[i].Self, listing[j].Self) 91 + }) 92 + for _, r := range listing { 93 + sort.Slice(r.Replies, func(i, j int) bool { 94 + return sortFunc(r.Replies[i], r.Replies[j]) 95 + }) 96 + } 97 + 98 + return listing 99 + } 100 + 101 + func (i *Issue) Participants() []string { 102 + participantSet := make(map[string]struct{}) 103 + participants := []string{} 104 + 105 + addParticipant := func(did string) { 106 + if _, exists := participantSet[did]; !exists { 107 + participantSet[did] = struct{}{} 108 + participants = append(participants, did) 109 + } 110 + } 111 + 112 + addParticipant(i.Did) 113 + 114 + for _, c := range i.Comments { 115 + addParticipant(c.Did) 116 + } 117 + 118 + return participants 119 + } 120 + 121 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 122 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 123 + if err != nil { 124 + created = time.Now() 125 + } 126 + 127 + body := "" 128 + if record.Body != nil { 129 + body = *record.Body 130 + } 131 + 132 + return Issue{ 133 + RepoAt: syntax.ATURI(record.Repo), 134 + Did: did, 135 + Rkey: rkey, 136 + Created: created, 137 + Title: record.Title, 138 + Body: body, 139 + Open: true, // new issues are open by default 140 + } 141 + } 142 + 143 + type IssueComment struct { 144 + Id int64 145 + Did string 146 + Rkey string 147 + IssueAt string 148 + ReplyTo *string 149 + Body string 150 + Created time.Time 151 + Edited *time.Time 152 + Deleted *time.Time 153 + } 154 + 155 + func (i *IssueComment) AtUri() syntax.ATURI { 156 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 157 + } 158 + 159 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 160 + return tangled.RepoIssueComment{ 161 + Body: i.Body, 162 + Issue: i.IssueAt, 163 + CreatedAt: i.Created.Format(time.RFC3339), 164 + ReplyTo: i.ReplyTo, 165 + } 166 + } 167 + 168 + func (i *IssueComment) IsTopLevel() bool { 169 + return i.ReplyTo == nil 170 + } 171 + 172 + func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 173 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 174 + if err != nil { 175 + created = time.Now() 176 + } 177 + 178 + ownerDid := did 179 + 180 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 181 + return nil, err 182 + } 183 + 184 + comment := IssueComment{ 185 + Did: ownerDid, 186 + Rkey: rkey, 187 + Body: record.Body, 188 + IssueAt: record.Issue, 189 + ReplyTo: record.ReplyTo, 190 + Created: created, 191 + } 192 + 193 + return &comment, nil 194 + }
+542
appview/models/label.go
··· 1 + package models 2 + 3 + import ( 4 + "context" 5 + "crypto/sha1" 6 + "encoding/hex" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "slices" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/api/atproto" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/consts" 18 + "tangled.org/core/idresolver" 19 + ) 20 + 21 + type ConcreteType string 22 + 23 + const ( 24 + ConcreteTypeNull ConcreteType = "null" 25 + ConcreteTypeString ConcreteType = "string" 26 + ConcreteTypeInt ConcreteType = "integer" 27 + ConcreteTypeBool ConcreteType = "boolean" 28 + ) 29 + 30 + type ValueTypeFormat string 31 + 32 + const ( 33 + ValueTypeFormatAny ValueTypeFormat = "any" 34 + ValueTypeFormatDid ValueTypeFormat = "did" 35 + ) 36 + 37 + // ValueType represents an atproto lexicon type definition with constraints 38 + type ValueType struct { 39 + Type ConcreteType `json:"type"` 40 + Format ValueTypeFormat `json:"format,omitempty"` 41 + Enum []string `json:"enum,omitempty"` 42 + } 43 + 44 + func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 45 + return tangled.LabelDefinition_ValueType{ 46 + Type: string(vt.Type), 47 + Format: string(vt.Format), 48 + Enum: vt.Enum, 49 + } 50 + } 51 + 52 + func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 53 + return ValueType{ 54 + Type: ConcreteType(record.Type), 55 + Format: ValueTypeFormat(record.Format), 56 + Enum: record.Enum, 57 + } 58 + } 59 + 60 + func (vt ValueType) IsConcreteType() bool { 61 + return vt.Type == ConcreteTypeNull || 62 + vt.Type == ConcreteTypeString || 63 + vt.Type == ConcreteTypeInt || 64 + vt.Type == ConcreteTypeBool 65 + } 66 + 67 + func (vt ValueType) IsNull() bool { 68 + return vt.Type == ConcreteTypeNull 69 + } 70 + 71 + func (vt ValueType) IsString() bool { 72 + return vt.Type == ConcreteTypeString 73 + } 74 + 75 + func (vt ValueType) IsInt() bool { 76 + return vt.Type == ConcreteTypeInt 77 + } 78 + 79 + func (vt ValueType) IsBool() bool { 80 + return vt.Type == ConcreteTypeBool 81 + } 82 + 83 + func (vt ValueType) IsEnum() bool { 84 + return len(vt.Enum) > 0 85 + } 86 + 87 + func (vt ValueType) IsDidFormat() bool { 88 + return vt.Format == ValueTypeFormatDid 89 + } 90 + 91 + func (vt ValueType) IsAnyFormat() bool { 92 + return vt.Format == ValueTypeFormatAny 93 + } 94 + 95 + type LabelDefinition struct { 96 + Id int64 97 + Did string 98 + Rkey string 99 + 100 + Name string 101 + ValueType ValueType 102 + Scope []string 103 + Color *string 104 + Multiple bool 105 + Created time.Time 106 + } 107 + 108 + func (l *LabelDefinition) AtUri() syntax.ATURI { 109 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 110 + } 111 + 112 + func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 113 + vt := l.ValueType.AsRecord() 114 + return tangled.LabelDefinition{ 115 + Name: l.Name, 116 + Color: l.Color, 117 + CreatedAt: l.Created.Format(time.RFC3339), 118 + Multiple: &l.Multiple, 119 + Scope: l.Scope, 120 + ValueType: &vt, 121 + } 122 + } 123 + 124 + // random color for a given seed 125 + func randomColor(seed string) string { 126 + hash := sha1.Sum([]byte(seed)) 127 + hexStr := hex.EncodeToString(hash[:]) 128 + r := hexStr[0:2] 129 + g := hexStr[2:4] 130 + b := hexStr[4:6] 131 + 132 + return fmt.Sprintf("#%s%s%s", r, g, b) 133 + } 134 + 135 + func (ld LabelDefinition) GetColor() string { 136 + if ld.Color == nil { 137 + seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 138 + color := randomColor(seed) 139 + return color 140 + } 141 + 142 + return *ld.Color 143 + } 144 + 145 + func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { 146 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 147 + if err != nil { 148 + created = time.Now() 149 + } 150 + 151 + multiple := false 152 + if record.Multiple != nil { 153 + multiple = *record.Multiple 154 + } 155 + 156 + var vt ValueType 157 + if record.ValueType != nil { 158 + vt = ValueTypeFromRecord(*record.ValueType) 159 + } 160 + 161 + return &LabelDefinition{ 162 + Did: did, 163 + Rkey: rkey, 164 + 165 + Name: record.Name, 166 + ValueType: vt, 167 + Scope: record.Scope, 168 + Color: record.Color, 169 + Multiple: multiple, 170 + Created: created, 171 + }, nil 172 + } 173 + 174 + type LabelOp struct { 175 + Id int64 176 + Did string 177 + Rkey string 178 + Subject syntax.ATURI 179 + Operation LabelOperation 180 + OperandKey string 181 + OperandValue string 182 + PerformedAt time.Time 183 + IndexedAt time.Time 184 + } 185 + 186 + func (l LabelOp) SortAt() time.Time { 187 + createdAt := l.PerformedAt 188 + indexedAt := l.IndexedAt 189 + 190 + // if we don't have an indexedat, fall back to now 191 + if indexedAt.IsZero() { 192 + indexedAt = time.Now() 193 + } 194 + 195 + // if createdat is invalid (before epoch), treat as null -> return zero time 196 + if createdAt.Before(time.UnixMicro(0)) { 197 + return time.Time{} 198 + } 199 + 200 + // if createdat is <= indexedat, use createdat 201 + if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { 202 + return createdAt 203 + } 204 + 205 + // otherwise, createdat is in the future relative to indexedat -> use indexedat 206 + return indexedAt 207 + } 208 + 209 + type LabelOperation string 210 + 211 + const ( 212 + LabelOperationAdd LabelOperation = "add" 213 + LabelOperationDel LabelOperation = "del" 214 + ) 215 + 216 + // a record can create multiple label ops 217 + func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 218 + performed, err := time.Parse(time.RFC3339, record.PerformedAt) 219 + if err != nil { 220 + performed = time.Now() 221 + } 222 + 223 + mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 224 + return LabelOp{ 225 + Did: did, 226 + Rkey: rkey, 227 + Subject: syntax.ATURI(record.Subject), 228 + OperandKey: operand.Key, 229 + OperandValue: operand.Value, 230 + PerformedAt: performed, 231 + } 232 + } 233 + 234 + var ops []LabelOp 235 + // deletes first, then additions 236 + for _, o := range record.Delete { 237 + if o != nil { 238 + op := mkOp(o) 239 + op.Operation = LabelOperationDel 240 + ops = append(ops, op) 241 + } 242 + } 243 + for _, o := range record.Add { 244 + if o != nil { 245 + op := mkOp(o) 246 + op.Operation = LabelOperationAdd 247 + ops = append(ops, op) 248 + } 249 + } 250 + 251 + return ops 252 + } 253 + 254 + func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 255 + if len(ops) == 0 { 256 + return tangled.LabelOp{} 257 + } 258 + 259 + // use the first operation to establish common fields 260 + first := ops[0] 261 + record := tangled.LabelOp{ 262 + Subject: string(first.Subject), 263 + PerformedAt: first.PerformedAt.Format(time.RFC3339), 264 + } 265 + 266 + var addOperands []*tangled.LabelOp_Operand 267 + var deleteOperands []*tangled.LabelOp_Operand 268 + 269 + for _, op := range ops { 270 + operand := &tangled.LabelOp_Operand{ 271 + Key: op.OperandKey, 272 + Value: op.OperandValue, 273 + } 274 + 275 + switch op.Operation { 276 + case LabelOperationAdd: 277 + addOperands = append(addOperands, operand) 278 + case LabelOperationDel: 279 + deleteOperands = append(deleteOperands, operand) 280 + default: 281 + return tangled.LabelOp{} 282 + } 283 + } 284 + 285 + record.Add = addOperands 286 + record.Delete = deleteOperands 287 + 288 + return record 289 + } 290 + 291 + type set = map[string]struct{} 292 + 293 + type LabelState struct { 294 + inner map[string]set 295 + } 296 + 297 + func NewLabelState() LabelState { 298 + return LabelState{ 299 + inner: make(map[string]set), 300 + } 301 + } 302 + 303 + func (s LabelState) Inner() map[string]set { 304 + return s.inner 305 + } 306 + 307 + func (s LabelState) ContainsLabel(l string) bool { 308 + if valset, exists := s.inner[l]; exists { 309 + if valset != nil { 310 + return true 311 + } 312 + } 313 + 314 + return false 315 + } 316 + 317 + // go maps behavior in templates make this necessary, 318 + // indexing a map and getting `set` in return is apparently truthy 319 + func (s LabelState) ContainsLabelAndVal(l, v string) bool { 320 + if valset, exists := s.inner[l]; exists { 321 + if _, exists := valset[v]; exists { 322 + return true 323 + } 324 + } 325 + 326 + return false 327 + } 328 + 329 + func (s LabelState) GetValSet(l string) set { 330 + if valset, exists := s.inner[l]; exists { 331 + return valset 332 + } else { 333 + return make(set) 334 + } 335 + } 336 + 337 + type LabelApplicationCtx struct { 338 + Defs map[string]*LabelDefinition // labelAt -> labelDef 339 + } 340 + 341 + var ( 342 + LabelNoOpError = errors.New("no-op") 343 + ) 344 + 345 + func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 346 + def, ok := c.Defs[op.OperandKey] 347 + if !ok { 348 + // this def was deleted, but an op exists, so we just skip over the op 349 + return nil 350 + } 351 + 352 + switch op.Operation { 353 + case LabelOperationAdd: 354 + // if valueset is empty, init it 355 + if state.inner[op.OperandKey] == nil { 356 + state.inner[op.OperandKey] = make(set) 357 + } 358 + 359 + // if valueset is populated & this val alr exists, this labelop is a noop 360 + if valueSet, exists := state.inner[op.OperandKey]; exists { 361 + if _, exists = valueSet[op.OperandValue]; exists { 362 + return LabelNoOpError 363 + } 364 + } 365 + 366 + if def.Multiple { 367 + // append to set 368 + state.inner[op.OperandKey][op.OperandValue] = struct{}{} 369 + } else { 370 + // reset to just this value 371 + state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 372 + } 373 + 374 + case LabelOperationDel: 375 + // if label DNE, then deletion is a no-op 376 + if valueSet, exists := state.inner[op.OperandKey]; !exists { 377 + return LabelNoOpError 378 + } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 379 + return LabelNoOpError 380 + } 381 + 382 + if def.Multiple { 383 + // remove from set 384 + delete(state.inner[op.OperandKey], op.OperandValue) 385 + } else { 386 + // reset the entire label 387 + delete(state.inner, op.OperandKey) 388 + } 389 + 390 + // if the map becomes empty, then set it to nil, this is just the inverse of add 391 + if len(state.inner[op.OperandKey]) == 0 { 392 + state.inner[op.OperandKey] = nil 393 + } 394 + 395 + } 396 + 397 + return nil 398 + } 399 + 400 + func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 401 + // sort label ops in sort order first 402 + slices.SortFunc(ops, func(a, b LabelOp) int { 403 + return a.SortAt().Compare(b.SortAt()) 404 + }) 405 + 406 + // apply ops in sequence 407 + for _, o := range ops { 408 + _ = c.ApplyLabelOp(state, o) 409 + } 410 + } 411 + 412 + // IsInverse checks if one label operation is the inverse of another 413 + // returns true if one is an add and the other is a delete with the same key and value 414 + func (op1 LabelOp) IsInverse(op2 LabelOp) bool { 415 + if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { 416 + return false 417 + } 418 + 419 + return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || 420 + (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) 421 + } 422 + 423 + // removes pairs of label operations that are inverses of each other 424 + // from the given slice. the function preserves the order of remaining operations. 425 + func ReduceLabelOps(ops []LabelOp) []LabelOp { 426 + if len(ops) <= 1 { 427 + return ops 428 + } 429 + 430 + keep := make([]bool, len(ops)) 431 + for i := range keep { 432 + keep[i] = true 433 + } 434 + 435 + for i := range ops { 436 + if !keep[i] { 437 + continue 438 + } 439 + 440 + for j := i + 1; j < len(ops); j++ { 441 + if !keep[j] { 442 + continue 443 + } 444 + 445 + if ops[i].IsInverse(ops[j]) { 446 + keep[i] = false 447 + keep[j] = false 448 + break // move to next i since this one is now eliminated 449 + } 450 + } 451 + } 452 + 453 + // build result slice with only kept operations 454 + var result []LabelOp 455 + for i, op := range ops { 456 + if keep[i] { 457 + result = append(result, op) 458 + } 459 + } 460 + 461 + return result 462 + } 463 + 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 472 + func DefaultLabelDefs() []string { 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 479 + } 480 + } 481 + 482 + func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 483 + resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 484 + if err != nil { 485 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 486 + } 487 + pdsEndpoint := resolved.PDSEndpoint() 488 + if pdsEndpoint == "" { 489 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 490 + } 491 + client := &xrpc.Client{ 492 + Host: pdsEndpoint, 493 + } 494 + 495 + var labelDefs []LabelDefinition 496 + 497 + for _, dl := range DefaultLabelDefs() { 498 + atUri := syntax.ATURI(dl) 499 + parsedUri, err := syntax.ParseATURI(string(atUri)) 500 + if err != nil { 501 + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 502 + } 503 + record, err := atproto.RepoGetRecord( 504 + context.Background(), 505 + client, 506 + "", 507 + parsedUri.Collection().String(), 508 + parsedUri.Authority().String(), 509 + parsedUri.RecordKey().String(), 510 + ) 511 + if err != nil { 512 + return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) 513 + } 514 + 515 + if record != nil { 516 + bytes, err := record.Value.MarshalJSON() 517 + if err != nil { 518 + return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) 519 + } 520 + 521 + raw := json.RawMessage(bytes) 522 + labelRecord := tangled.LabelDefinition{} 523 + err = json.Unmarshal(raw, &labelRecord) 524 + if err != nil { 525 + return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) 526 + } 527 + 528 + labelDef, err := LabelDefinitionFromRecord( 529 + parsedUri.Authority().String(), 530 + parsedUri.RecordKey().String(), 531 + labelRecord, 532 + ) 533 + if err != nil { 534 + return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) 535 + } 536 + 537 + labelDefs = append(labelDefs, *labelDef) 538 + } 539 + } 540 + 541 + return labelDefs, nil 542 + }
+14
appview/models/language.go
··· 1 + package models 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + type RepoLanguage struct { 8 + Id int64 9 + RepoAt syntax.ATURI 10 + Ref string 11 + IsDefaultRef bool 12 + Language string 13 + Bytes int64 14 + }
+82
appview/models/notifications.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type NotificationType string 8 + 9 + const ( 10 + NotificationTypeRepoStarred NotificationType = "repo_starred" 11 + NotificationTypeIssueCreated NotificationType = "issue_created" 12 + NotificationTypeIssueCommented NotificationType = "issue_commented" 13 + NotificationTypePullCreated NotificationType = "pull_created" 14 + NotificationTypePullCommented NotificationType = "pull_commented" 15 + NotificationTypeFollowed NotificationType = "followed" 16 + NotificationTypePullMerged NotificationType = "pull_merged" 17 + NotificationTypeIssueClosed NotificationType = "issue_closed" 18 + NotificationTypePullClosed NotificationType = "pull_closed" 19 + ) 20 + 21 + type Notification struct { 22 + ID int64 23 + RecipientDid string 24 + ActorDid string 25 + Type NotificationType 26 + EntityType string 27 + EntityId string 28 + Read bool 29 + Created time.Time 30 + 31 + // foreign key references 32 + RepoId *int64 33 + IssueId *int64 34 + PullId *int64 35 + } 36 + 37 + // lucide icon that represents this notification 38 + func (n *Notification) Icon() string { 39 + switch n.Type { 40 + case NotificationTypeRepoStarred: 41 + return "star" 42 + case NotificationTypeIssueCreated: 43 + return "circle-dot" 44 + case NotificationTypeIssueCommented: 45 + return "message-square" 46 + case NotificationTypeIssueClosed: 47 + return "ban" 48 + case NotificationTypePullCreated: 49 + return "git-pull-request-create" 50 + case NotificationTypePullCommented: 51 + return "message-square" 52 + case NotificationTypePullMerged: 53 + return "git-merge" 54 + case NotificationTypePullClosed: 55 + return "git-pull-request-closed" 56 + case NotificationTypeFollowed: 57 + return "user-plus" 58 + default: 59 + return "" 60 + } 61 + } 62 + 63 + type NotificationWithEntity struct { 64 + *Notification 65 + Repo *Repo 66 + Issue *Issue 67 + Pull *Pull 68 + } 69 + 70 + type NotificationPreferences struct { 71 + ID int64 72 + UserDid string 73 + RepoStarred bool 74 + IssueCreated bool 75 + IssueCommented bool 76 + PullCreated bool 77 + PullCommented bool 78 + Followed bool 79 + PullMerged bool 80 + IssueClosed bool 81 + EmailNotifications bool 82 + }
+130
appview/models/pipeline.go
··· 1 + package models 2 + 3 + import ( 4 + "slices" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/go-git/go-git/v5/plumbing" 9 + spindle "tangled.org/core/spindle/models" 10 + "tangled.org/core/workflow" 11 + ) 12 + 13 + type Pipeline struct { 14 + Id int 15 + Rkey string 16 + Knot string 17 + RepoOwner syntax.DID 18 + RepoName string 19 + TriggerId int 20 + Sha string 21 + Created time.Time 22 + 23 + // populate when querying for reverse mappings 24 + Trigger *Trigger 25 + Statuses map[string]WorkflowStatus 26 + } 27 + 28 + type WorkflowStatus struct { 29 + Data []PipelineStatus 30 + } 31 + 32 + func (w WorkflowStatus) Latest() PipelineStatus { 33 + return w.Data[len(w.Data)-1] 34 + } 35 + 36 + // time taken by this workflow to reach an "end state" 37 + func (w WorkflowStatus) TimeTaken() time.Duration { 38 + var start, end *time.Time 39 + for _, s := range w.Data { 40 + if s.Status.IsStart() { 41 + start = &s.Created 42 + } 43 + if s.Status.IsFinish() { 44 + end = &s.Created 45 + } 46 + } 47 + 48 + if start != nil && end != nil && end.After(*start) { 49 + return end.Sub(*start) 50 + } 51 + 52 + return 0 53 + } 54 + 55 + func (p Pipeline) Counts() map[string]int { 56 + m := make(map[string]int) 57 + for _, w := range p.Statuses { 58 + m[w.Latest().Status.String()] += 1 59 + } 60 + return m 61 + } 62 + 63 + func (p Pipeline) TimeTaken() time.Duration { 64 + var s time.Duration 65 + for _, w := range p.Statuses { 66 + s += w.TimeTaken() 67 + } 68 + return s 69 + } 70 + 71 + func (p Pipeline) Workflows() []string { 72 + var ws []string 73 + for v := range p.Statuses { 74 + ws = append(ws, v) 75 + } 76 + slices.Sort(ws) 77 + return ws 78 + } 79 + 80 + // if we know that a spindle has picked up this pipeline, then it is Responding 81 + func (p Pipeline) IsResponding() bool { 82 + return len(p.Statuses) != 0 83 + } 84 + 85 + type Trigger struct { 86 + Id int 87 + Kind workflow.TriggerKind 88 + 89 + // push trigger fields 90 + PushRef *string 91 + PushNewSha *string 92 + PushOldSha *string 93 + 94 + // pull request trigger fields 95 + PRSourceBranch *string 96 + PRTargetBranch *string 97 + PRSourceSha *string 98 + PRAction *string 99 + } 100 + 101 + func (t *Trigger) IsPush() bool { 102 + return t != nil && t.Kind == workflow.TriggerKindPush 103 + } 104 + 105 + func (t *Trigger) IsPullRequest() bool { 106 + return t != nil && t.Kind == workflow.TriggerKindPullRequest 107 + } 108 + 109 + func (t *Trigger) TargetRef() string { 110 + if t.IsPush() { 111 + return plumbing.ReferenceName(*t.PushRef).Short() 112 + } else if t.IsPullRequest() { 113 + return *t.PRTargetBranch 114 + } 115 + 116 + return "" 117 + } 118 + 119 + type PipelineStatus struct { 120 + ID int 121 + Spindle string 122 + Rkey string 123 + PipelineKnot string 124 + PipelineRkey string 125 + Created time.Time 126 + Workflow string 127 + Status spindle.StatusKind 128 + Error *string 129 + ExitCode int 130 + }
+177
appview/models/profile.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/api/tangled" 8 + ) 9 + 10 + type Profile struct { 11 + // ids 12 + ID int 13 + Did string 14 + 15 + // data 16 + Description string 17 + IncludeBluesky bool 18 + Location string 19 + Links [5]string 20 + Stats [2]VanityStat 21 + PinnedRepos [6]syntax.ATURI 22 + } 23 + 24 + func (p Profile) IsLinksEmpty() bool { 25 + for _, l := range p.Links { 26 + if l != "" { 27 + return false 28 + } 29 + } 30 + return true 31 + } 32 + 33 + func (p Profile) IsStatsEmpty() bool { 34 + for _, s := range p.Stats { 35 + if s.Kind != "" { 36 + return false 37 + } 38 + } 39 + return true 40 + } 41 + 42 + func (p Profile) IsPinnedReposEmpty() bool { 43 + for _, r := range p.PinnedRepos { 44 + if r != "" { 45 + return false 46 + } 47 + } 48 + return true 49 + } 50 + 51 + type VanityStatKind string 52 + 53 + const ( 54 + VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" 55 + VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" 56 + VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" 57 + VanityStatOpenIssueCount VanityStatKind = "open-issue-count" 58 + VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 59 + VanityStatRepositoryCount VanityStatKind = "repository-count" 60 + ) 61 + 62 + func (v VanityStatKind) String() string { 63 + switch v { 64 + case VanityStatMergedPRCount: 65 + return "Merged PRs" 66 + case VanityStatClosedPRCount: 67 + return "Closed PRs" 68 + case VanityStatOpenPRCount: 69 + return "Open PRs" 70 + case VanityStatOpenIssueCount: 71 + return "Open Issues" 72 + case VanityStatClosedIssueCount: 73 + return "Closed Issues" 74 + case VanityStatRepositoryCount: 75 + return "Repositories" 76 + } 77 + return "" 78 + } 79 + 80 + type VanityStat struct { 81 + Kind VanityStatKind 82 + Value uint64 83 + } 84 + 85 + func (p *Profile) ProfileAt() syntax.ATURI { 86 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) 87 + } 88 + 89 + type RepoEvent struct { 90 + Repo *Repo 91 + Source *Repo 92 + } 93 + 94 + type ProfileTimeline struct { 95 + ByMonth []ByMonth 96 + } 97 + 98 + func (p *ProfileTimeline) IsEmpty() bool { 99 + if p == nil { 100 + return true 101 + } 102 + 103 + for _, m := range p.ByMonth { 104 + if !m.IsEmpty() { 105 + return false 106 + } 107 + } 108 + 109 + return true 110 + } 111 + 112 + type ByMonth struct { 113 + RepoEvents []RepoEvent 114 + IssueEvents IssueEvents 115 + PullEvents PullEvents 116 + } 117 + 118 + func (b ByMonth) IsEmpty() bool { 119 + return len(b.RepoEvents) == 0 && 120 + len(b.IssueEvents.Items) == 0 && 121 + len(b.PullEvents.Items) == 0 122 + } 123 + 124 + type IssueEvents struct { 125 + Items []*Issue 126 + } 127 + 128 + type IssueEventStats struct { 129 + Open int 130 + Closed int 131 + } 132 + 133 + func (i IssueEvents) Stats() IssueEventStats { 134 + var open, closed int 135 + for _, issue := range i.Items { 136 + if issue.Open { 137 + open += 1 138 + } else { 139 + closed += 1 140 + } 141 + } 142 + 143 + return IssueEventStats{ 144 + Open: open, 145 + Closed: closed, 146 + } 147 + } 148 + 149 + type PullEvents struct { 150 + Items []*Pull 151 + } 152 + 153 + func (p PullEvents) Stats() PullEventStats { 154 + var open, merged, closed int 155 + for _, pull := range p.Items { 156 + switch pull.State { 157 + case PullOpen: 158 + open += 1 159 + case PullMerged: 160 + merged += 1 161 + case PullClosed: 162 + closed += 1 163 + } 164 + } 165 + 166 + return PullEventStats{ 167 + Open: open, 168 + Merged: merged, 169 + Closed: closed, 170 + } 171 + } 172 + 173 + type PullEventStats struct { 174 + Closed int 175 + Open int 176 + Merged int 177 + }
+25
appview/models/pubkey.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + ) 7 + 8 + type PublicKey struct { 9 + Did string `json:"did"` 10 + Key string `json:"key"` 11 + Name string `json:"name"` 12 + Rkey string `json:"rkey"` 13 + Created *time.Time 14 + } 15 + 16 + func (p PublicKey) MarshalJSON() ([]byte, error) { 17 + type Alias PublicKey 18 + return json.Marshal(&struct { 19 + Created string `json:"created"` 20 + *Alias 21 + }{ 22 + Created: p.Created.Format(time.RFC3339), 23 + Alias: (*Alias)(&p), 24 + }) 25 + }
+357
appview/models/pull.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "slices" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/patchutil" 13 + "tangled.org/core/types" 14 + ) 15 + 16 + type PullState int 17 + 18 + const ( 19 + PullClosed PullState = iota 20 + PullOpen 21 + PullMerged 22 + PullDeleted 23 + ) 24 + 25 + func (p PullState) String() string { 26 + switch p { 27 + case PullOpen: 28 + return "open" 29 + case PullMerged: 30 + return "merged" 31 + case PullClosed: 32 + return "closed" 33 + case PullDeleted: 34 + return "deleted" 35 + default: 36 + return "closed" 37 + } 38 + } 39 + 40 + func (p PullState) IsOpen() bool { 41 + return p == PullOpen 42 + } 43 + func (p PullState) IsMerged() bool { 44 + return p == PullMerged 45 + } 46 + func (p PullState) IsClosed() bool { 47 + return p == PullClosed 48 + } 49 + func (p PullState) IsDeleted() bool { 50 + return p == PullDeleted 51 + } 52 + 53 + type Pull struct { 54 + // ids 55 + ID int 56 + PullId int 57 + 58 + // at ids 59 + RepoAt syntax.ATURI 60 + OwnerDid string 61 + Rkey string 62 + 63 + // content 64 + Title string 65 + Body string 66 + TargetBranch string 67 + State PullState 68 + Submissions []*PullSubmission 69 + 70 + // stacking 71 + StackId string // nullable string 72 + ChangeId string // nullable string 73 + ParentChangeId string // nullable string 74 + 75 + // meta 76 + Created time.Time 77 + PullSource *PullSource 78 + 79 + // optionally, populate this when querying for reverse mappings 80 + Labels LabelState 81 + Repo *Repo 82 + } 83 + 84 + func (p Pull) AsRecord() tangled.RepoPull { 85 + var source *tangled.RepoPull_Source 86 + if p.PullSource != nil { 87 + s := p.PullSource.AsRecord() 88 + source = &s 89 + source.Sha = p.LatestSha() 90 + } 91 + 92 + record := tangled.RepoPull{ 93 + Title: p.Title, 94 + Body: &p.Body, 95 + CreatedAt: p.Created.Format(time.RFC3339), 96 + Target: &tangled.RepoPull_Target{ 97 + Repo: p.RepoAt.String(), 98 + Branch: p.TargetBranch, 99 + }, 100 + Patch: p.LatestPatch(), 101 + Source: source, 102 + } 103 + return record 104 + } 105 + 106 + type PullSource struct { 107 + Branch string 108 + RepoAt *syntax.ATURI 109 + 110 + // optionally populate this for reverse mappings 111 + Repo *Repo 112 + } 113 + 114 + func (p PullSource) AsRecord() tangled.RepoPull_Source { 115 + var repoAt *string 116 + if p.RepoAt != nil { 117 + s := p.RepoAt.String() 118 + repoAt = &s 119 + } 120 + record := tangled.RepoPull_Source{ 121 + Branch: p.Branch, 122 + Repo: repoAt, 123 + } 124 + return record 125 + } 126 + 127 + type PullSubmission struct { 128 + // ids 129 + ID int 130 + 131 + // at ids 132 + PullAt syntax.ATURI 133 + 134 + // content 135 + RoundNumber int 136 + Patch string 137 + Comments []PullComment 138 + SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 139 + 140 + // meta 141 + Created time.Time 142 + } 143 + 144 + type PullComment struct { 145 + // ids 146 + ID int 147 + PullId int 148 + SubmissionId int 149 + 150 + // at ids 151 + RepoAt string 152 + OwnerDid string 153 + CommentAt string 154 + 155 + // content 156 + Body string 157 + 158 + // meta 159 + Created time.Time 160 + } 161 + 162 + func (p *Pull) LatestPatch() string { 163 + latestSubmission := p.Submissions[p.LastRoundNumber()] 164 + return latestSubmission.Patch 165 + } 166 + 167 + func (p *Pull) LatestSha() string { 168 + latestSubmission := p.Submissions[p.LastRoundNumber()] 169 + return latestSubmission.SourceRev 170 + } 171 + 172 + func (p *Pull) PullAt() syntax.ATURI { 173 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 174 + } 175 + 176 + func (p *Pull) LastRoundNumber() int { 177 + return len(p.Submissions) - 1 178 + } 179 + 180 + func (p *Pull) IsPatchBased() bool { 181 + return p.PullSource == nil 182 + } 183 + 184 + func (p *Pull) IsBranchBased() bool { 185 + if p.PullSource != nil { 186 + if p.PullSource.RepoAt != nil { 187 + return p.PullSource.RepoAt == &p.RepoAt 188 + } else { 189 + // no repo specified 190 + return true 191 + } 192 + } 193 + return false 194 + } 195 + 196 + func (p *Pull) IsForkBased() bool { 197 + if p.PullSource != nil { 198 + if p.PullSource.RepoAt != nil { 199 + // make sure repos are different 200 + return p.PullSource.RepoAt != &p.RepoAt 201 + } 202 + } 203 + return false 204 + } 205 + 206 + func (p *Pull) IsStacked() bool { 207 + return p.StackId != "" 208 + } 209 + 210 + func (p *Pull) Participants() []string { 211 + participantSet := make(map[string]struct{}) 212 + participants := []string{} 213 + 214 + addParticipant := func(did string) { 215 + if _, exists := participantSet[did]; !exists { 216 + participantSet[did] = struct{}{} 217 + participants = append(participants, did) 218 + } 219 + } 220 + 221 + addParticipant(p.OwnerDid) 222 + 223 + for _, s := range p.Submissions { 224 + for _, sp := range s.Participants() { 225 + addParticipant(sp) 226 + } 227 + } 228 + 229 + return participants 230 + } 231 + 232 + func (s PullSubmission) IsFormatPatch() bool { 233 + return patchutil.IsFormatPatch(s.Patch) 234 + } 235 + 236 + func (s PullSubmission) AsFormatPatch() []types.FormatPatch { 237 + patches, err := patchutil.ExtractPatches(s.Patch) 238 + if err != nil { 239 + log.Println("error extracting patches from submission:", err) 240 + return []types.FormatPatch{} 241 + } 242 + 243 + return patches 244 + } 245 + 246 + func (s *PullSubmission) Participants() []string { 247 + participantSet := make(map[string]struct{}) 248 + participants := []string{} 249 + 250 + addParticipant := func(did string) { 251 + if _, exists := participantSet[did]; !exists { 252 + participantSet[did] = struct{}{} 253 + participants = append(participants, did) 254 + } 255 + } 256 + 257 + addParticipant(s.PullAt.Authority().String()) 258 + 259 + for _, c := range s.Comments { 260 + addParticipant(c.OwnerDid) 261 + } 262 + 263 + return participants 264 + } 265 + 266 + type Stack []*Pull 267 + 268 + // position of this pull in the stack 269 + func (stack Stack) Position(pull *Pull) int { 270 + return slices.IndexFunc(stack, func(p *Pull) bool { 271 + return p.ChangeId == pull.ChangeId 272 + }) 273 + } 274 + 275 + // all pulls below this pull (including self) in this stack 276 + // 277 + // nil if this pull does not belong to this stack 278 + func (stack Stack) Below(pull *Pull) Stack { 279 + position := stack.Position(pull) 280 + 281 + if position < 0 { 282 + return nil 283 + } 284 + 285 + return stack[position:] 286 + } 287 + 288 + // all pulls below this pull (excluding self) in this stack 289 + func (stack Stack) StrictlyBelow(pull *Pull) Stack { 290 + below := stack.Below(pull) 291 + 292 + if len(below) > 0 { 293 + return below[1:] 294 + } 295 + 296 + return nil 297 + } 298 + 299 + // all pulls above this pull (including self) in this stack 300 + func (stack Stack) Above(pull *Pull) Stack { 301 + position := stack.Position(pull) 302 + 303 + if position < 0 { 304 + return nil 305 + } 306 + 307 + return stack[:position+1] 308 + } 309 + 310 + // all pulls below this pull (excluding self) in this stack 311 + func (stack Stack) StrictlyAbove(pull *Pull) Stack { 312 + above := stack.Above(pull) 313 + 314 + if len(above) > 0 { 315 + return above[:len(above)-1] 316 + } 317 + 318 + return nil 319 + } 320 + 321 + // the combined format-patches of all the newest submissions in this stack 322 + func (stack Stack) CombinedPatch() string { 323 + // go in reverse order because the bottom of the stack is the last element in the slice 324 + var combined strings.Builder 325 + for idx := range stack { 326 + pull := stack[len(stack)-1-idx] 327 + combined.WriteString(pull.LatestPatch()) 328 + combined.WriteString("\n") 329 + } 330 + return combined.String() 331 + } 332 + 333 + // filter out PRs that are "active" 334 + // 335 + // PRs that are still open are active 336 + func (stack Stack) Mergeable() Stack { 337 + var mergeable Stack 338 + 339 + for _, p := range stack { 340 + // stop at the first merged PR 341 + if p.State == PullMerged || p.State == PullClosed { 342 + break 343 + } 344 + 345 + // skip over deleted PRs 346 + if p.State != PullDeleted { 347 + mergeable = append(mergeable, p) 348 + } 349 + } 350 + 351 + return mergeable 352 + } 353 + 354 + type BranchDeleteStatus struct { 355 + Repo *Repo 356 + Branch string 357 + }
+14
appview/models/punchcard.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type Punch struct { 6 + Did string 7 + Date time.Time 8 + Count int 9 + } 10 + 11 + type Punchcard struct { 12 + Total int 13 + Punches []Punch 14 + }
+62
appview/models/reaction.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type ReactionKind string 10 + 11 + const ( 12 + Like ReactionKind = "👍" 13 + Unlike ReactionKind = "👎" 14 + Laugh ReactionKind = "😆" 15 + Celebration ReactionKind = "🎉" 16 + Confused ReactionKind = "🫤" 17 + Heart ReactionKind = "❤️" 18 + Rocket ReactionKind = "🚀" 19 + Eyes ReactionKind = "👀" 20 + ) 21 + 22 + func (rk ReactionKind) String() string { 23 + return string(rk) 24 + } 25 + 26 + var OrderedReactionKinds = []ReactionKind{ 27 + Like, 28 + Unlike, 29 + Laugh, 30 + Celebration, 31 + Confused, 32 + Heart, 33 + Rocket, 34 + Eyes, 35 + } 36 + 37 + func ParseReactionKind(raw string) (ReactionKind, bool) { 38 + k, ok := (map[string]ReactionKind{ 39 + "👍": Like, 40 + "👎": Unlike, 41 + "😆": Laugh, 42 + "🎉": Celebration, 43 + "🫤": Confused, 44 + "❤️": Heart, 45 + "🚀": Rocket, 46 + "👀": Eyes, 47 + })[raw] 48 + return k, ok 49 + } 50 + 51 + type Reaction struct { 52 + ReactedByDid string 53 + ThreadAt syntax.ATURI 54 + Created time.Time 55 + Rkey string 56 + Kind ReactionKind 57 + } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+44
appview/models/registration.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + // Registration represents a knot registration. Knot would've been a better 6 + // name but we're stuck with this for historical reasons. 7 + type Registration struct { 8 + Id int64 9 + Domain string 10 + ByDid string 11 + Created *time.Time 12 + Registered *time.Time 13 + NeedsUpgrade bool 14 + } 15 + 16 + func (r *Registration) Status() Status { 17 + if r.NeedsUpgrade { 18 + return NeedsUpgrade 19 + } else if r.Registered != nil { 20 + return Registered 21 + } else { 22 + return Pending 23 + } 24 + } 25 + 26 + func (r *Registration) IsRegistered() bool { 27 + return r.Status() == Registered 28 + } 29 + 30 + func (r *Registration) IsNeedsUpgrade() bool { 31 + return r.Status() == NeedsUpgrade 32 + } 33 + 34 + func (r *Registration) IsPending() bool { 35 + return r.Status() == Pending 36 + } 37 + 38 + type Status uint32 39 + 40 + const ( 41 + Registered Status = iota 42 + Pending 43 + NeedsUpgrade 44 + )
+93
appview/models/repo.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + securejoin "github.com/cyphar/filepath-securejoin" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Repo struct { 13 + Id int64 14 + Did string 15 + Name string 16 + Knot string 17 + Rkey string 18 + Created time.Time 19 + Description string 20 + Spindle string 21 + Labels []string 22 + 23 + // optionally, populate this when querying for reverse mappings 24 + RepoStats *RepoStats 25 + 26 + // optional 27 + Source string 28 + } 29 + 30 + func (r *Repo) AsRecord() tangled.Repo { 31 + var source, spindle, description *string 32 + 33 + if r.Source != "" { 34 + source = &r.Source 35 + } 36 + 37 + if r.Spindle != "" { 38 + spindle = &r.Spindle 39 + } 40 + 41 + if r.Description != "" { 42 + description = &r.Description 43 + } 44 + 45 + return tangled.Repo{ 46 + Knot: r.Knot, 47 + Name: r.Name, 48 + Description: description, 49 + CreatedAt: r.Created.Format(time.RFC3339), 50 + Source: source, 51 + Spindle: spindle, 52 + Labels: r.Labels, 53 + } 54 + } 55 + 56 + func (r Repo) RepoAt() syntax.ATURI { 57 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 58 + } 59 + 60 + func (r Repo) DidSlashRepo() string { 61 + p, _ := securejoin.SecureJoin(r.Did, r.Name) 62 + return p 63 + } 64 + 65 + type RepoStats struct { 66 + Language string 67 + StarCount int 68 + IssueCount IssueCount 69 + PullCount PullCount 70 + } 71 + 72 + type IssueCount struct { 73 + Open int 74 + Closed int 75 + } 76 + 77 + type PullCount struct { 78 + Open int 79 + Merged int 80 + Closed int 81 + Deleted int 82 + } 83 + 84 + type RepoLabel struct { 85 + Id int64 86 + RepoAt syntax.ATURI 87 + LabelAt syntax.ATURI 88 + } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+10
appview/models/signup.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + }
+25
appview/models/spindle.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Spindle struct { 10 + Id int 11 + Owner syntax.DID 12 + Instance string 13 + Verified *time.Time 14 + Created time.Time 15 + NeedsUpgrade bool 16 + } 17 + 18 + type SpindleMember struct { 19 + Id int 20 + Did syntax.DID // owner of the record 21 + Rkey string // rkey of the record 22 + Instance string 23 + Subject syntax.DID // the member being added 24 + Created time.Time 25 + }
+17
appview/models/star.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Star struct { 10 + StarredByDid string 11 + RepoAt syntax.ATURI 12 + Created time.Time 13 + Rkey string 14 + 15 + // optionally, populate this when querying for reverse mappings 16 + Repo *Repo 17 + }
+95
appview/models/string.go
··· 1 + package models 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "io" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + ) 13 + 14 + type String struct { 15 + Did syntax.DID 16 + Rkey string 17 + 18 + Filename string 19 + Description string 20 + Contents string 21 + Created time.Time 22 + Edited *time.Time 23 + } 24 + 25 + func (s *String) StringAt() syntax.ATURI { 26 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 + } 28 + 29 + func (s *String) AsRecord() tangled.String { 30 + return tangled.String{ 31 + Filename: s.Filename, 32 + Description: s.Description, 33 + Contents: s.Contents, 34 + CreatedAt: s.Created.Format(time.RFC3339), 35 + } 36 + } 37 + 38 + func StringFromRecord(did, rkey string, record tangled.String) String { 39 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 40 + if err != nil { 41 + created = time.Now() 42 + } 43 + return String{ 44 + Did: syntax.DID(did), 45 + Rkey: rkey, 46 + Filename: record.Filename, 47 + Description: record.Description, 48 + Contents: record.Contents, 49 + Created: created, 50 + } 51 + } 52 + 53 + type StringStats struct { 54 + LineCount uint64 55 + ByteCount uint64 56 + } 57 + 58 + func (s String) Stats() StringStats { 59 + lineCount, err := countLines(strings.NewReader(s.Contents)) 60 + if err != nil { 61 + // non-fatal 62 + // TODO: log this? 63 + } 64 + 65 + return StringStats{ 66 + LineCount: uint64(lineCount), 67 + ByteCount: uint64(len(s.Contents)), 68 + } 69 + } 70 + 71 + func countLines(r io.Reader) (int, error) { 72 + buf := make([]byte, 32*1024) 73 + bufLen := 0 74 + count := 0 75 + nl := []byte{'\n'} 76 + 77 + for { 78 + c, err := r.Read(buf) 79 + if c > 0 { 80 + bufLen += c 81 + } 82 + count += bytes.Count(buf[:c], nl) 83 + 84 + switch { 85 + case err == io.EOF: 86 + /* handle last line not having a newline at the end */ 87 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 88 + count++ 89 + } 90 + return count, nil 91 + case err != nil: 92 + return 0, err 93 + } 94 + } 95 + }
+23
appview/models/timeline.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type TimelineEvent struct { 6 + *Repo 7 + *Follow 8 + *Star 9 + 10 + EventAt time.Time 11 + 12 + // optional: populate only if Repo is a fork 13 + Source *Repo 14 + 15 + // optional: populate only if event is Follow 16 + *Profile 17 + *FollowStats 18 + *FollowStatus 19 + 20 + // optional: populate only if event is Repo 21 + IsStarred bool 22 + StarCount int64 23 + }
+166
appview/notifications/notifications.go
··· 1 + package notifications 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/middleware" 11 + "tangled.org/core/appview/oauth" 12 + "tangled.org/core/appview/pages" 13 + "tangled.org/core/appview/pagination" 14 + ) 15 + 16 + type Notifications struct { 17 + db *db.DB 18 + oauth *oauth.OAuth 19 + pages *pages.Pages 20 + } 21 + 22 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 23 + return &Notifications{ 24 + db: database, 25 + oauth: oauthHandler, 26 + pages: pagesHandler, 27 + } 28 + } 29 + 30 + func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 31 + r := chi.NewRouter() 32 + 33 + r.Get("/count", n.getUnreadCount) 34 + 35 + r.Group(func(r chi.Router) { 36 + r.Use(middleware.AuthMiddleware(n.oauth)) 37 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 38 + r.Post("/{id}/read", n.markRead) 39 + r.Post("/read-all", n.markAllRead) 40 + r.Delete("/{id}", n.deleteNotification) 41 + }) 42 + 43 + return r 44 + } 45 + 46 + func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 + user := n.oauth.GetUser(r) 48 + 49 + page, ok := r.Context().Value("page").(pagination.Page) 50 + if !ok { 51 + log.Println("failed to get page") 52 + page = pagination.FirstPage() 53 + } 54 + 55 + total, err := db.CountNotifications( 56 + n.db, 57 + db.FilterEq("recipient_did", user.Did), 58 + ) 59 + if err != nil { 60 + log.Println("failed to get total notifications:", err) 61 + n.pages.Error500(w) 62 + return 63 + } 64 + 65 + notifications, err := db.GetNotificationsWithEntities( 66 + n.db, 67 + page, 68 + db.FilterEq("recipient_did", user.Did), 69 + ) 70 + if err != nil { 71 + log.Println("failed to get notifications:", err) 72 + n.pages.Error500(w) 73 + return 74 + } 75 + 76 + err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 77 + if err != nil { 78 + log.Println("failed to mark notifications as read:", err) 79 + } 80 + 81 + unreadCount := 0 82 + 83 + n.pages.Notifications(w, pages.NotificationsParams{ 84 + LoggedInUser: user, 85 + Notifications: notifications, 86 + UnreadCount: unreadCount, 87 + Page: page, 88 + Total: total, 89 + }) 90 + } 91 + 92 + func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 93 + user := n.oauth.GetUser(r) 94 + if user == nil { 95 + return 96 + } 97 + 98 + count, err := db.CountNotifications( 99 + n.db, 100 + db.FilterEq("recipient_did", user.Did), 101 + db.FilterEq("read", 0), 102 + ) 103 + if err != nil { 104 + http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 105 + return 106 + } 107 + 108 + params := pages.NotificationCountParams{ 109 + Count: count, 110 + } 111 + err = n.pages.NotificationCount(w, params) 112 + if err != nil { 113 + http.Error(w, "Failed to render count", http.StatusInternalServerError) 114 + return 115 + } 116 + } 117 + 118 + func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 119 + userDid := n.oauth.GetDid(r) 120 + 121 + idStr := chi.URLParam(r, "id") 122 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 123 + if err != nil { 124 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 125 + return 126 + } 127 + 128 + err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 129 + if err != nil { 130 + http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 131 + return 132 + } 133 + 134 + w.WriteHeader(http.StatusNoContent) 135 + } 136 + 137 + func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 138 + userDid := n.oauth.GetDid(r) 139 + 140 + err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 141 + if err != nil { 142 + http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 143 + return 144 + } 145 + 146 + http.Redirect(w, r, "/notifications", http.StatusSeeOther) 147 + } 148 + 149 + func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { 150 + userDid := n.oauth.GetDid(r) 151 + 152 + idStr := chi.URLParam(r, "id") 153 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 154 + if err != nil { 155 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 156 + return 157 + } 158 + 159 + err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 160 + if err != nil { 161 + http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 162 + return 163 + } 164 + 165 + w.WriteHeader(http.StatusOK) 166 + }
+429
appview/notify/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + "tangled.org/core/idresolver" 11 + ) 12 + 13 + type databaseNotifier struct { 14 + db *db.DB 15 + res *idresolver.Resolver 16 + } 17 + 18 + func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier { 19 + return &databaseNotifier{ 20 + db: database, 21 + res: resolver, 22 + } 23 + } 24 + 25 + var _ notify.Notifier = &databaseNotifier{} 26 + 27 + func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 28 + // no-op for now 29 + } 30 + 31 + func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 + var err error 33 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 34 + if err != nil { 35 + log.Printf("NewStar: failed to get repos: %v", err) 36 + return 37 + } 38 + 39 + // don't notify yourself 40 + if repo.Did == star.StarredByDid { 41 + return 42 + } 43 + 44 + // check if user wants these notifications 45 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 46 + if err != nil { 47 + log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err) 48 + return 49 + } 50 + if !prefs.RepoStarred { 51 + return 52 + } 53 + 54 + notification := &models.Notification{ 55 + RecipientDid: repo.Did, 56 + ActorDid: star.StarredByDid, 57 + Type: models.NotificationTypeRepoStarred, 58 + EntityType: "repo", 59 + EntityId: string(star.RepoAt), 60 + RepoId: &repo.Id, 61 + } 62 + err = n.db.CreateNotification(ctx, notification) 63 + if err != nil { 64 + log.Printf("NewStar: failed to create notification: %v", err) 65 + return 66 + } 67 + } 68 + 69 + func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 70 + // no-op 71 + } 72 + 73 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 75 + if err != nil { 76 + log.Printf("NewIssue: failed to get repos: %v", err) 77 + return 78 + } 79 + 80 + if repo.Did == issue.Did { 81 + return 82 + } 83 + 84 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 85 + if err != nil { 86 + log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 87 + return 88 + } 89 + if !prefs.IssueCreated { 90 + return 91 + } 92 + 93 + notification := &models.Notification{ 94 + RecipientDid: repo.Did, 95 + ActorDid: issue.Did, 96 + Type: models.NotificationTypeIssueCreated, 97 + EntityType: "issue", 98 + EntityId: string(issue.AtUri()), 99 + RepoId: &repo.Id, 100 + IssueId: &issue.Id, 101 + } 102 + 103 + err = n.db.CreateNotification(ctx, notification) 104 + if err != nil { 105 + log.Printf("NewIssue: failed to create notification: %v", err) 106 + return 107 + } 108 + } 109 + 110 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 111 + issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 112 + if err != nil { 113 + log.Printf("NewIssueComment: failed to get issues: %v", err) 114 + return 115 + } 116 + if len(issues) == 0 { 117 + log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 118 + return 119 + } 120 + issue := issues[0] 121 + 122 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 + if err != nil { 124 + log.Printf("NewIssueComment: failed to get repos: %v", err) 125 + return 126 + } 127 + 128 + recipients := make(map[string]bool) 129 + 130 + // notify issue author (if not the commenter) 131 + if issue.Did != comment.Did { 132 + prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did) 133 + if err == nil && prefs.IssueCommented { 134 + recipients[issue.Did] = true 135 + } else if err != nil { 136 + log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err) 137 + } 138 + } 139 + 140 + // notify repo owner (if not the commenter and not already added) 141 + if repo.Did != comment.Did && repo.Did != issue.Did { 142 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 143 + if err == nil && prefs.IssueCommented { 144 + recipients[repo.Did] = true 145 + } else if err != nil { 146 + log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 147 + } 148 + } 149 + 150 + // create notifications for all recipients 151 + for recipientDid := range recipients { 152 + notification := &models.Notification{ 153 + RecipientDid: recipientDid, 154 + ActorDid: comment.Did, 155 + Type: models.NotificationTypeIssueCommented, 156 + EntityType: "issue", 157 + EntityId: string(issue.AtUri()), 158 + RepoId: &repo.Id, 159 + IssueId: &issue.Id, 160 + } 161 + 162 + err = n.db.CreateNotification(ctx, notification) 163 + if err != nil { 164 + log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err) 165 + } 166 + } 167 + } 168 + 169 + func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 170 + prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid) 171 + if err != nil { 172 + log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err) 173 + return 174 + } 175 + if !prefs.Followed { 176 + return 177 + } 178 + 179 + notification := &models.Notification{ 180 + RecipientDid: follow.SubjectDid, 181 + ActorDid: follow.UserDid, 182 + Type: models.NotificationTypeFollowed, 183 + EntityType: "follow", 184 + EntityId: follow.UserDid, 185 + } 186 + 187 + err = n.db.CreateNotification(ctx, notification) 188 + if err != nil { 189 + log.Printf("NewFollow: failed to create notification: %v", err) 190 + return 191 + } 192 + } 193 + 194 + func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 195 + // no-op 196 + } 197 + 198 + func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 199 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 200 + if err != nil { 201 + log.Printf("NewPull: failed to get repos: %v", err) 202 + return 203 + } 204 + 205 + if repo.Did == pull.OwnerDid { 206 + return 207 + } 208 + 209 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 210 + if err != nil { 211 + log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 212 + return 213 + } 214 + if !prefs.PullCreated { 215 + return 216 + } 217 + 218 + notification := &models.Notification{ 219 + RecipientDid: repo.Did, 220 + ActorDid: pull.OwnerDid, 221 + Type: models.NotificationTypePullCreated, 222 + EntityType: "pull", 223 + EntityId: string(pull.RepoAt), 224 + RepoId: &repo.Id, 225 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 226 + } 227 + 228 + err = n.db.CreateNotification(ctx, notification) 229 + if err != nil { 230 + log.Printf("NewPull: failed to create notification: %v", err) 231 + return 232 + } 233 + } 234 + 235 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 236 + pulls, err := db.GetPulls(n.db, 237 + db.FilterEq("repo_at", comment.RepoAt), 238 + db.FilterEq("pull_id", comment.PullId)) 239 + if err != nil { 240 + log.Printf("NewPullComment: failed to get pulls: %v", err) 241 + return 242 + } 243 + if len(pulls) == 0 { 244 + log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId) 245 + return 246 + } 247 + pull := pulls[0] 248 + 249 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 250 + if err != nil { 251 + log.Printf("NewPullComment: failed to get repos: %v", err) 252 + return 253 + } 254 + 255 + recipients := make(map[string]bool) 256 + 257 + // notify pull request author (if not the commenter) 258 + if pull.OwnerDid != comment.OwnerDid { 259 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 260 + if err == nil && prefs.PullCommented { 261 + recipients[pull.OwnerDid] = true 262 + } else if err != nil { 263 + log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err) 264 + } 265 + } 266 + 267 + // notify repo owner (if not the commenter and not already added) 268 + if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid { 269 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 270 + if err == nil && prefs.PullCommented { 271 + recipients[repo.Did] = true 272 + } else if err != nil { 273 + log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 274 + } 275 + } 276 + 277 + for recipientDid := range recipients { 278 + notification := &models.Notification{ 279 + RecipientDid: recipientDid, 280 + ActorDid: comment.OwnerDid, 281 + Type: models.NotificationTypePullCommented, 282 + EntityType: "pull", 283 + EntityId: comment.RepoAt, 284 + RepoId: &repo.Id, 285 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 286 + } 287 + 288 + err = n.db.CreateNotification(ctx, notification) 289 + if err != nil { 290 + log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err) 291 + } 292 + } 293 + } 294 + 295 + func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 296 + // no-op 297 + } 298 + 299 + func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) { 300 + // no-op 301 + } 302 + 303 + func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) { 304 + // no-op 305 + } 306 + 307 + func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) { 308 + // no-op 309 + } 310 + 311 + func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 + // Get repo details 313 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 314 + if err != nil { 315 + log.Printf("NewIssueClosed: failed to get repos: %v", err) 316 + return 317 + } 318 + 319 + // Don't notify yourself 320 + if repo.Did == issue.Did { 321 + return 322 + } 323 + 324 + // Check if user wants these notifications 325 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 326 + if err != nil { 327 + log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err) 328 + return 329 + } 330 + if !prefs.IssueClosed { 331 + return 332 + } 333 + 334 + notification := &models.Notification{ 335 + RecipientDid: repo.Did, 336 + ActorDid: issue.Did, 337 + Type: models.NotificationTypeIssueClosed, 338 + EntityType: "issue", 339 + EntityId: string(issue.AtUri()), 340 + RepoId: &repo.Id, 341 + IssueId: &issue.Id, 342 + } 343 + 344 + err = n.db.CreateNotification(ctx, notification) 345 + if err != nil { 346 + log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 + return 348 + } 349 + } 350 + 351 + func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 352 + // Get repo details 353 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 354 + if err != nil { 355 + log.Printf("NewPullMerged: failed to get repos: %v", err) 356 + return 357 + } 358 + 359 + // Don't notify yourself 360 + if repo.Did == pull.OwnerDid { 361 + return 362 + } 363 + 364 + // Check if user wants these notifications 365 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 366 + if err != nil { 367 + log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 368 + return 369 + } 370 + if !prefs.PullMerged { 371 + return 372 + } 373 + 374 + notification := &models.Notification{ 375 + RecipientDid: pull.OwnerDid, 376 + ActorDid: repo.Did, 377 + Type: models.NotificationTypePullMerged, 378 + EntityType: "pull", 379 + EntityId: string(pull.RepoAt), 380 + RepoId: &repo.Id, 381 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 382 + } 383 + 384 + err = n.db.CreateNotification(ctx, notification) 385 + if err != nil { 386 + log.Printf("NewPullMerged: failed to create notification: %v", err) 387 + return 388 + } 389 + } 390 + 391 + func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 + // Get repo details 393 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 + if err != nil { 395 + log.Printf("NewPullClosed: failed to get repos: %v", err) 396 + return 397 + } 398 + 399 + // Don't notify yourself 400 + if repo.Did == pull.OwnerDid { 401 + return 402 + } 403 + 404 + // Check if user wants these notifications - reuse pull_merged preference for now 405 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 406 + if err != nil { 407 + log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 408 + return 409 + } 410 + if !prefs.PullMerged { 411 + return 412 + } 413 + 414 + notification := &models.Notification{ 415 + RecipientDid: pull.OwnerDid, 416 + ActorDid: repo.Did, 417 + Type: models.NotificationTypePullClosed, 418 + EntityType: "pull", 419 + EntityId: string(pull.RepoAt), 420 + RepoId: &repo.Id, 421 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 422 + } 423 + 424 + err = n.db.CreateNotification(ctx, notification) 425 + if err != nil { 426 + log.Printf("NewPullClosed: failed to create notification: %v", err) 427 + return 428 + } 429 + }
+51 -10
appview/notify/merged_notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 - "tangled.sh/tangled.sh/core/appview/db" 6 + "tangled.org/core/appview/models" 7 7 ) 8 8 9 9 type mergedNotifier struct { ··· 16 16 17 17 var _ Notifier = &mergedNotifier{} 18 18 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 19 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 20 for _, notifier := range m.notifiers { 21 21 notifier.NewRepo(ctx, repo) 22 22 } 23 23 } 24 24 25 - func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) { 25 + func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 26 for _, notifier := range m.notifiers { 27 27 notifier.NewStar(ctx, star) 28 28 } 29 29 } 30 - func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) { 30 + func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 31 for _, notifier := range m.notifiers { 32 32 notifier.DeleteStar(ctx, star) 33 33 } 34 34 } 35 35 36 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 36 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 37 for _, notifier := range m.notifiers { 38 38 notifier.NewIssue(ctx, issue) 39 39 } 40 40 } 41 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 42 + for _, notifier := range m.notifiers { 43 + notifier.NewIssueComment(ctx, comment) 44 + } 45 + } 41 46 42 - func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 47 + func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 48 + for _, notifier := range m.notifiers { 49 + notifier.NewIssueClosed(ctx, issue) 50 + } 51 + } 52 + 53 + func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 43 54 for _, notifier := range m.notifiers { 44 55 notifier.NewFollow(ctx, follow) 45 56 } 46 57 } 47 - func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 58 + func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 48 59 for _, notifier := range m.notifiers { 49 60 notifier.DeleteFollow(ctx, follow) 50 61 } 51 62 } 52 63 53 - func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) { 64 + func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 54 65 for _, notifier := range m.notifiers { 55 66 notifier.NewPull(ctx, pull) 56 67 } 57 68 } 58 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 69 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 59 70 for _, notifier := range m.notifiers { 60 71 notifier.NewPullComment(ctx, comment) 61 72 } 62 73 } 63 74 64 - func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 75 + func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 + for _, notifier := range m.notifiers { 77 + notifier.NewPullMerged(ctx, pull) 78 + } 79 + } 80 + 81 + func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 + for _, notifier := range m.notifiers { 83 + notifier.NewPullClosed(ctx, pull) 84 + } 85 + } 86 + 87 + func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 65 88 for _, notifier := range m.notifiers { 66 89 notifier.UpdateProfile(ctx, profile) 67 90 } 68 91 } 92 + 93 + func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 94 + for _, notifier := range m.notifiers { 95 + notifier.NewString(ctx, string) 96 + } 97 + } 98 + 99 + func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 100 + for _, notifier := range m.notifiers { 101 + notifier.EditString(ctx, string) 102 + } 103 + } 104 + 105 + func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 106 + for _, notifier := range m.notifiers { 107 + notifier.DeleteString(ctx, did, rkey) 108 + } 109 + }
+35 -19
appview/notify/notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 - "tangled.sh/tangled.sh/core/appview/db" 6 + "tangled.org/core/appview/models" 7 7 ) 8 8 9 9 type Notifier interface { 10 - NewRepo(ctx context.Context, repo *db.Repo) 10 + NewRepo(ctx context.Context, repo *models.Repo) 11 11 12 - NewStar(ctx context.Context, star *db.Star) 13 - DeleteStar(ctx context.Context, star *db.Star) 12 + NewStar(ctx context.Context, star *models.Star) 13 + DeleteStar(ctx context.Context, star *models.Star) 14 14 15 - NewIssue(ctx context.Context, issue *db.Issue) 15 + NewIssue(ctx context.Context, issue *models.Issue) 16 + NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 + NewIssueClosed(ctx context.Context, issue *models.Issue) 18 + 19 + NewFollow(ctx context.Context, follow *models.Follow) 20 + DeleteFollow(ctx context.Context, follow *models.Follow) 16 21 17 - NewFollow(ctx context.Context, follow *db.Follow) 18 - DeleteFollow(ctx context.Context, follow *db.Follow) 22 + NewPull(ctx context.Context, pull *models.Pull) 23 + NewPullComment(ctx context.Context, comment *models.PullComment) 24 + NewPullMerged(ctx context.Context, pull *models.Pull) 25 + NewPullClosed(ctx context.Context, pull *models.Pull) 19 26 20 - NewPull(ctx context.Context, pull *db.Pull) 21 - NewPullComment(ctx context.Context, comment *db.PullComment) 27 + UpdateProfile(ctx context.Context, profile *models.Profile) 22 28 23 - UpdateProfile(ctx context.Context, profile *db.Profile) 29 + NewString(ctx context.Context, s *models.String) 30 + EditString(ctx context.Context, s *models.String) 31 + DeleteString(ctx context.Context, did, rkey string) 24 32 } 25 33 26 34 // BaseNotifier is a listener that does nothing ··· 28 36 29 37 var _ Notifier = &BaseNotifier{} 30 38 31 - func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {} 39 + func (m *BaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {} 32 40 33 - func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {} 34 - func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {} 41 + func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 42 + func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 35 43 36 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {} 44 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 45 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 46 + func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {} 47 + 48 + func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 49 + func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 37 50 38 - func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {} 39 - func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} 51 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 52 + func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 53 + func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {} 54 + func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {} 40 55 41 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {} 42 - func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 56 + func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 43 57 44 - func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {} 58 + func (m *BaseNotifier) NewString(ctx context.Context, s *models.String) {} 59 + func (m *BaseNotifier) EditString(ctx context.Context, s *models.String) {} 60 + func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+219
appview/notify/posthog/notifier.go
··· 1 + package posthog 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "github.com/posthog/posthog-go" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + ) 11 + 12 + type posthogNotifier struct { 13 + client posthog.Client 14 + notify.BaseNotifier 15 + } 16 + 17 + func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 + return &posthogNotifier{ 19 + client, 20 + notify.BaseNotifier{}, 21 + } 22 + } 23 + 24 + var _ notify.Notifier = &posthogNotifier{} 25 + 26 + func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 27 + err := n.client.Enqueue(posthog.Capture{ 28 + DistinctId: repo.Did, 29 + Event: "new_repo", 30 + Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 + }) 32 + if err != nil { 33 + log.Println("failed to enqueue posthog event:", err) 34 + } 35 + } 36 + 37 + func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 38 + err := n.client.Enqueue(posthog.Capture{ 39 + DistinctId: star.StarredByDid, 40 + Event: "star", 41 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 + }) 43 + if err != nil { 44 + log.Println("failed to enqueue posthog event:", err) 45 + } 46 + } 47 + 48 + func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 49 + err := n.client.Enqueue(posthog.Capture{ 50 + DistinctId: star.StarredByDid, 51 + Event: "unstar", 52 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 + }) 54 + if err != nil { 55 + log.Println("failed to enqueue posthog event:", err) 56 + } 57 + } 58 + 59 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 + err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.Did, 62 + Event: "new_issue", 63 + Properties: posthog.Properties{ 64 + "repo_at": issue.RepoAt.String(), 65 + "issue_id": issue.IssueId, 66 + }, 67 + }) 68 + if err != nil { 69 + log.Println("failed to enqueue posthog event:", err) 70 + } 71 + } 72 + 73 + func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 74 + err := n.client.Enqueue(posthog.Capture{ 75 + DistinctId: pull.OwnerDid, 76 + Event: "new_pull", 77 + Properties: posthog.Properties{ 78 + "repo_at": pull.RepoAt, 79 + "pull_id": pull.PullId, 80 + }, 81 + }) 82 + if err != nil { 83 + log.Println("failed to enqueue posthog event:", err) 84 + } 85 + } 86 + 87 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 88 + err := n.client.Enqueue(posthog.Capture{ 89 + DistinctId: comment.OwnerDid, 90 + Event: "new_pull_comment", 91 + Properties: posthog.Properties{ 92 + "repo_at": comment.RepoAt, 93 + "pull_id": comment.PullId, 94 + }, 95 + }) 96 + if err != nil { 97 + log.Println("failed to enqueue posthog event:", err) 98 + } 99 + } 100 + 101 + func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: pull.OwnerDid, 104 + Event: "pull_closed", 105 + Properties: posthog.Properties{ 106 + "repo_at": pull.RepoAt, 107 + "pull_id": pull.PullId, 108 + }, 109 + }) 110 + if err != nil { 111 + log.Println("failed to enqueue posthog event:", err) 112 + } 113 + } 114 + 115 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 116 + err := n.client.Enqueue(posthog.Capture{ 117 + DistinctId: follow.UserDid, 118 + Event: "follow", 119 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 120 + }) 121 + if err != nil { 122 + log.Println("failed to enqueue posthog event:", err) 123 + } 124 + } 125 + 126 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 127 + err := n.client.Enqueue(posthog.Capture{ 128 + DistinctId: follow.UserDid, 129 + Event: "unfollow", 130 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 131 + }) 132 + if err != nil { 133 + log.Println("failed to enqueue posthog event:", err) 134 + } 135 + } 136 + 137 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 138 + err := n.client.Enqueue(posthog.Capture{ 139 + DistinctId: profile.Did, 140 + Event: "edit_profile", 141 + }) 142 + if err != nil { 143 + log.Println("failed to enqueue posthog event:", err) 144 + } 145 + } 146 + 147 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 148 + err := n.client.Enqueue(posthog.Capture{ 149 + DistinctId: did, 150 + Event: "delete_string", 151 + Properties: posthog.Properties{"rkey": rkey}, 152 + }) 153 + if err != nil { 154 + log.Println("failed to enqueue posthog event:", err) 155 + } 156 + } 157 + 158 + func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) { 159 + err := n.client.Enqueue(posthog.Capture{ 160 + DistinctId: string.Did.String(), 161 + Event: "edit_string", 162 + Properties: posthog.Properties{"rkey": string.Rkey}, 163 + }) 164 + if err != nil { 165 + log.Println("failed to enqueue posthog event:", err) 166 + } 167 + } 168 + 169 + func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) { 170 + err := n.client.Enqueue(posthog.Capture{ 171 + DistinctId: string.Did.String(), 172 + Event: "new_string", 173 + Properties: posthog.Properties{"rkey": string.Rkey}, 174 + }) 175 + if err != nil { 176 + log.Println("failed to enqueue posthog event:", err) 177 + } 178 + } 179 + 180 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 181 + err := n.client.Enqueue(posthog.Capture{ 182 + DistinctId: comment.Did, 183 + Event: "new_issue_comment", 184 + Properties: posthog.Properties{ 185 + "issue_at": comment.IssueAt, 186 + }, 187 + }) 188 + if err != nil { 189 + log.Println("failed to enqueue posthog event:", err) 190 + } 191 + } 192 + 193 + func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 194 + err := n.client.Enqueue(posthog.Capture{ 195 + DistinctId: issue.Did, 196 + Event: "issue_closed", 197 + Properties: posthog.Properties{ 198 + "repo_at": issue.RepoAt.String(), 199 + "issue_id": issue.IssueId, 200 + }, 201 + }) 202 + if err != nil { 203 + log.Println("failed to enqueue posthog event:", err) 204 + } 205 + } 206 + 207 + func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 208 + err := n.client.Enqueue(posthog.Capture{ 209 + DistinctId: pull.OwnerDid, 210 + Event: "pull_merged", 211 + Properties: posthog.Properties{ 212 + "repo_at": pull.RepoAt, 213 + "pull_id": pull.PullId, 214 + }, 215 + }) 216 + if err != nil { 217 + log.Println("failed to enqueue posthog event:", err) 218 + } 219 + }
-24
appview/oauth/client/oauth_client.go
··· 1 - package client 2 - 3 - import ( 4 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 5 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 6 - ) 7 - 8 - type OAuthClient struct { 9 - *oauth.Client 10 - } 11 - 12 - func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { 13 - k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) 14 - if err != nil { 15 - return nil, err 16 - } 17 - 18 - cli, err := oauth.NewClient(oauth.ClientArgs{ 19 - ClientId: clientId, 20 - ClientJwk: k, 21 - RedirectUri: redirectUri, 22 - }) 23 - return &OAuthClient{cli}, err 24 - }
+2 -1
appview/oauth/consts.go
··· 1 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session" 4 + SessionName = "appview-session-v2" 5 5 SessionHandle = "handle" 6 6 SessionDid = "did" 7 + SessionId = "id" 7 8 SessionPds = "pds" 8 9 SessionAccessJwt = "accessJwt" 9 10 SessionRefreshJwt = "refreshJwt"
-545
appview/oauth/handler/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "slices" 12 - "strings" 13 - "time" 14 - 15 - "github.com/go-chi/chi/v5" 16 - "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 - "github.com/posthog/posthog-go" 19 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 20 - tangled "tangled.sh/tangled.sh/core/api/tangled" 21 - sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 22 - "tangled.sh/tangled.sh/core/appview/config" 23 - "tangled.sh/tangled.sh/core/appview/db" 24 - "tangled.sh/tangled.sh/core/appview/middleware" 25 - "tangled.sh/tangled.sh/core/appview/oauth" 26 - "tangled.sh/tangled.sh/core/appview/oauth/client" 27 - "tangled.sh/tangled.sh/core/appview/pages" 28 - "tangled.sh/tangled.sh/core/idresolver" 29 - "tangled.sh/tangled.sh/core/rbac" 30 - "tangled.sh/tangled.sh/core/tid" 31 - ) 32 - 33 - const ( 34 - oauthScope = "atproto transition:generic" 35 - ) 36 - 37 - type OAuthHandler struct { 38 - config *config.Config 39 - pages *pages.Pages 40 - idResolver *idresolver.Resolver 41 - sess *sessioncache.SessionStore 42 - db *db.DB 43 - store *sessions.CookieStore 44 - oauth *oauth.OAuth 45 - enforcer *rbac.Enforcer 46 - posthog posthog.Client 47 - } 48 - 49 - func New( 50 - config *config.Config, 51 - pages *pages.Pages, 52 - idResolver *idresolver.Resolver, 53 - db *db.DB, 54 - sess *sessioncache.SessionStore, 55 - store *sessions.CookieStore, 56 - oauth *oauth.OAuth, 57 - enforcer *rbac.Enforcer, 58 - posthog posthog.Client, 59 - ) *OAuthHandler { 60 - return &OAuthHandler{ 61 - config: config, 62 - pages: pages, 63 - idResolver: idResolver, 64 - db: db, 65 - sess: sess, 66 - store: store, 67 - oauth: oauth, 68 - enforcer: enforcer, 69 - posthog: posthog, 70 - } 71 - } 72 - 73 - func (o *OAuthHandler) Router() http.Handler { 74 - r := chi.NewRouter() 75 - 76 - r.Get("/login", o.login) 77 - r.Post("/login", o.login) 78 - 79 - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 80 - 81 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 82 - r.Get("/oauth/jwks.json", o.jwks) 83 - r.Get("/oauth/callback", o.callback) 84 - return r 85 - } 86 - 87 - func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 88 - w.Header().Set("Content-Type", "application/json") 89 - w.WriteHeader(http.StatusOK) 90 - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 91 - } 92 - 93 - func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 94 - jwks := o.config.OAuth.Jwks 95 - pubKey, err := pubKeyFromJwk(jwks) 96 - if err != nil { 97 - log.Printf("error parsing public key: %v", err) 98 - http.Error(w, err.Error(), http.StatusInternalServerError) 99 - return 100 - } 101 - 102 - response := helpers.CreateJwksResponseObject(pubKey) 103 - 104 - w.Header().Set("Content-Type", "application/json") 105 - w.WriteHeader(http.StatusOK) 106 - json.NewEncoder(w).Encode(response) 107 - } 108 - 109 - func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 110 - switch r.Method { 111 - case http.MethodGet: 112 - returnURL := r.URL.Query().Get("return_url") 113 - o.pages.Login(w, pages.LoginParams{ 114 - ReturnUrl: returnURL, 115 - }) 116 - case http.MethodPost: 117 - handle := r.FormValue("handle") 118 - 119 - // when users copy their handle from bsky.app, it tends to have these characters around it: 120 - // 121 - // @nelind.dk: 122 - // \u202a ensures that the handle is always rendered left to right and 123 - // \u202c reverts that so the rest of the page renders however it should 124 - handle = strings.TrimPrefix(handle, "\u202a") 125 - handle = strings.TrimSuffix(handle, "\u202c") 126 - 127 - // `@` is harmless 128 - handle = strings.TrimPrefix(handle, "@") 129 - 130 - // basic handle validation 131 - if !strings.Contains(handle, ".") { 132 - log.Println("invalid handle format", "raw", handle) 133 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 134 - return 135 - } 136 - 137 - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 138 - if err != nil { 139 - log.Println("failed to resolve handle:", err) 140 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 141 - return 142 - } 143 - self := o.oauth.ClientMetadata() 144 - oauthClient, err := client.NewClient( 145 - self.ClientID, 146 - o.config.OAuth.Jwks, 147 - self.RedirectURIs[0], 148 - ) 149 - 150 - if err != nil { 151 - log.Println("failed to create oauth client:", err) 152 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 153 - return 154 - } 155 - 156 - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 157 - if err != nil { 158 - log.Println("failed to resolve auth server:", err) 159 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 160 - return 161 - } 162 - 163 - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 164 - if err != nil { 165 - log.Println("failed to fetch auth server metadata:", err) 166 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 167 - return 168 - } 169 - 170 - dpopKey, err := helpers.GenerateKey(nil) 171 - if err != nil { 172 - log.Println("failed to generate dpop key:", err) 173 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 174 - return 175 - } 176 - 177 - dpopKeyJson, err := json.Marshal(dpopKey) 178 - if err != nil { 179 - log.Println("failed to marshal dpop key:", err) 180 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 181 - return 182 - } 183 - 184 - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 185 - if err != nil { 186 - log.Println("failed to send par auth request:", err) 187 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 188 - return 189 - } 190 - 191 - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 192 - Did: resolved.DID.String(), 193 - PdsUrl: resolved.PDSEndpoint(), 194 - Handle: handle, 195 - AuthserverIss: authMeta.Issuer, 196 - PkceVerifier: parResp.PkceVerifier, 197 - DpopAuthserverNonce: parResp.DpopAuthserverNonce, 198 - DpopPrivateJwk: string(dpopKeyJson), 199 - State: parResp.State, 200 - ReturnUrl: r.FormValue("return_url"), 201 - }) 202 - if err != nil { 203 - log.Println("failed to save oauth request:", err) 204 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 205 - return 206 - } 207 - 208 - u, _ := url.Parse(authMeta.AuthorizationEndpoint) 209 - query := url.Values{} 210 - query.Add("client_id", self.ClientID) 211 - query.Add("request_uri", parResp.RequestUri) 212 - u.RawQuery = query.Encode() 213 - o.pages.HxRedirect(w, u.String()) 214 - } 215 - } 216 - 217 - func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 218 - state := r.FormValue("state") 219 - 220 - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 221 - if err != nil { 222 - log.Println("failed to get oauth request:", err) 223 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 224 - return 225 - } 226 - 227 - defer func() { 228 - err := o.sess.DeleteRequestByState(r.Context(), state) 229 - if err != nil { 230 - log.Println("failed to delete oauth request for state:", state, err) 231 - } 232 - }() 233 - 234 - error := r.FormValue("error") 235 - errorDescription := r.FormValue("error_description") 236 - if error != "" || errorDescription != "" { 237 - log.Printf("error: %s, %s", error, errorDescription) 238 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 239 - return 240 - } 241 - 242 - code := r.FormValue("code") 243 - if code == "" { 244 - log.Println("missing code for state: ", state) 245 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 246 - return 247 - } 248 - 249 - iss := r.FormValue("iss") 250 - if iss == "" { 251 - log.Println("missing iss for state: ", state) 252 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 253 - return 254 - } 255 - 256 - if iss != oauthRequest.AuthserverIss { 257 - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 258 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 259 - return 260 - } 261 - 262 - self := o.oauth.ClientMetadata() 263 - 264 - oauthClient, err := client.NewClient( 265 - self.ClientID, 266 - o.config.OAuth.Jwks, 267 - self.RedirectURIs[0], 268 - ) 269 - 270 - if err != nil { 271 - log.Println("failed to create oauth client:", err) 272 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 273 - return 274 - } 275 - 276 - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 277 - if err != nil { 278 - log.Println("failed to parse jwk:", err) 279 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 280 - return 281 - } 282 - 283 - tokenResp, err := oauthClient.InitialTokenRequest( 284 - r.Context(), 285 - code, 286 - oauthRequest.AuthserverIss, 287 - oauthRequest.PkceVerifier, 288 - oauthRequest.DpopAuthserverNonce, 289 - jwk, 290 - ) 291 - if err != nil { 292 - log.Println("failed to get token:", err) 293 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 294 - return 295 - } 296 - 297 - if tokenResp.Scope != oauthScope { 298 - log.Println("scope doesn't match:", tokenResp.Scope) 299 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 300 - return 301 - } 302 - 303 - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 304 - if err != nil { 305 - log.Println("failed to save session:", err) 306 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 307 - return 308 - } 309 - 310 - log.Println("session saved successfully") 311 - go o.addToDefaultKnot(oauthRequest.Did) 312 - go o.addToDefaultSpindle(oauthRequest.Did) 313 - 314 - if !o.config.Core.Dev { 315 - err = o.posthog.Enqueue(posthog.Capture{ 316 - DistinctId: oauthRequest.Did, 317 - Event: "signin", 318 - }) 319 - if err != nil { 320 - log.Println("failed to enqueue posthog event:", err) 321 - } 322 - } 323 - 324 - returnUrl := oauthRequest.ReturnUrl 325 - if returnUrl == "" { 326 - returnUrl = "/" 327 - } 328 - 329 - http.Redirect(w, r, returnUrl, http.StatusFound) 330 - } 331 - 332 - func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 333 - err := o.oauth.ClearSession(r, w) 334 - if err != nil { 335 - log.Println("failed to clear session:", err) 336 - http.Redirect(w, r, "/", http.StatusFound) 337 - return 338 - } 339 - 340 - log.Println("session cleared successfully") 341 - o.pages.HxRedirect(w, "/login") 342 - } 343 - 344 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 345 - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 346 - if err != nil { 347 - return nil, err 348 - } 349 - pubKey, err := k.PublicKey() 350 - if err != nil { 351 - return nil, err 352 - } 353 - return pubKey, nil 354 - } 355 - 356 - var ( 357 - tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 358 - icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 359 - 360 - defaultSpindle = "spindle.tangled.sh" 361 - defaultKnot = "knot1.tangled.sh" 362 - ) 363 - 364 - func (o *OAuthHandler) addToDefaultSpindle(did string) { 365 - // use the tangled.sh app password to get an accessJwt 366 - // and create an sh.tangled.spindle.member record with that 367 - spindleMembers, err := db.GetSpindleMembers( 368 - o.db, 369 - db.FilterEq("instance", "spindle.tangled.sh"), 370 - db.FilterEq("subject", did), 371 - ) 372 - if err != nil { 373 - log.Printf("failed to get spindle members for did %s: %v", did, err) 374 - return 375 - } 376 - 377 - if len(spindleMembers) != 0 { 378 - log.Printf("did %s is already a member of the default spindle", did) 379 - return 380 - } 381 - 382 - log.Printf("adding %s to default spindle", did) 383 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid) 384 - if err != nil { 385 - log.Printf("failed to create session: %s", err) 386 - return 387 - } 388 - 389 - record := tangled.SpindleMember{ 390 - LexiconTypeID: "sh.tangled.spindle.member", 391 - Subject: did, 392 - Instance: defaultSpindle, 393 - CreatedAt: time.Now().Format(time.RFC3339), 394 - } 395 - 396 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 397 - log.Printf("failed to add member to default spindle: %s", err) 398 - return 399 - } 400 - 401 - log.Printf("successfully added %s to default spindle", did) 402 - } 403 - 404 - func (o *OAuthHandler) addToDefaultKnot(did string) { 405 - // use the tangled.sh app password to get an accessJwt 406 - // and create an sh.tangled.spindle.member record with that 407 - 408 - allKnots, err := o.enforcer.GetKnotsForUser(did) 409 - if err != nil { 410 - log.Printf("failed to get knot members for did %s: %v", did, err) 411 - return 412 - } 413 - 414 - if slices.Contains(allKnots, defaultKnot) { 415 - log.Printf("did %s is already a member of the default knot", did) 416 - return 417 - } 418 - 419 - log.Printf("adding %s to default knot", did) 420 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid) 421 - if err != nil { 422 - log.Printf("failed to create session: %s", err) 423 - return 424 - } 425 - 426 - record := tangled.KnotMember{ 427 - LexiconTypeID: "sh.tangled.knot.member", 428 - Subject: did, 429 - Domain: defaultKnot, 430 - CreatedAt: time.Now().Format(time.RFC3339), 431 - } 432 - 433 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 434 - log.Printf("failed to add member to default knot: %s", err) 435 - return 436 - } 437 - 438 - if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil { 439 - log.Printf("failed to set up enforcer rules: %s", err) 440 - return 441 - } 442 - 443 - log.Printf("successfully added %s to default Knot", did) 444 - } 445 - 446 - // create a session using apppasswords 447 - type session struct { 448 - AccessJwt string `json:"accessJwt"` 449 - PdsEndpoint string 450 - Did string 451 - } 452 - 453 - func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 454 - if appPassword == "" { 455 - return nil, fmt.Errorf("no app password configured, skipping member addition") 456 - } 457 - 458 - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 459 - if err != nil { 460 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 461 - } 462 - 463 - pdsEndpoint := resolved.PDSEndpoint() 464 - if pdsEndpoint == "" { 465 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 466 - } 467 - 468 - sessionPayload := map[string]string{ 469 - "identifier": did, 470 - "password": appPassword, 471 - } 472 - sessionBytes, err := json.Marshal(sessionPayload) 473 - if err != nil { 474 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 475 - } 476 - 477 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 478 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 479 - if err != nil { 480 - return nil, fmt.Errorf("failed to create session request: %v", err) 481 - } 482 - sessionReq.Header.Set("Content-Type", "application/json") 483 - 484 - client := &http.Client{Timeout: 30 * time.Second} 485 - sessionResp, err := client.Do(sessionReq) 486 - if err != nil { 487 - return nil, fmt.Errorf("failed to create session: %v", err) 488 - } 489 - defer sessionResp.Body.Close() 490 - 491 - if sessionResp.StatusCode != http.StatusOK { 492 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 493 - } 494 - 495 - var session session 496 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 497 - return nil, fmt.Errorf("failed to decode session response: %v", err) 498 - } 499 - 500 - session.PdsEndpoint = pdsEndpoint 501 - session.Did = did 502 - 503 - return &session, nil 504 - } 505 - 506 - func (s *session) putRecord(record any, collection string) error { 507 - recordBytes, err := json.Marshal(record) 508 - if err != nil { 509 - return fmt.Errorf("failed to marshal knot member record: %w", err) 510 - } 511 - 512 - payload := map[string]any{ 513 - "repo": s.Did, 514 - "collection": collection, 515 - "rkey": tid.TID(), 516 - "record": json.RawMessage(recordBytes), 517 - } 518 - 519 - payloadBytes, err := json.Marshal(payload) 520 - if err != nil { 521 - return fmt.Errorf("failed to marshal request payload: %w", err) 522 - } 523 - 524 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 525 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 526 - if err != nil { 527 - return fmt.Errorf("failed to create HTTP request: %w", err) 528 - } 529 - 530 - req.Header.Set("Content-Type", "application/json") 531 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 532 - 533 - client := &http.Client{Timeout: 30 * time.Second} 534 - resp, err := client.Do(req) 535 - if err != nil { 536 - return fmt.Errorf("failed to add user to default service: %w", err) 537 - } 538 - defer resp.Body.Close() 539 - 540 - if resp.StatusCode != http.StatusOK { 541 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 542 - } 543 - 544 - return nil 545 - }
+65
appview/oauth/handler.go
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + ) 11 + 12 + func (o *OAuth) Router() http.Handler { 13 + r := chi.NewRouter() 14 + 15 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 16 + r.Get("/oauth/jwks.json", o.jwks) 17 + r.Get("/oauth/callback", o.callback) 18 + return r 19 + } 20 + 21 + func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 22 + doc := o.ClientApp.Config.ClientMetadata() 23 + doc.JWKSURI = &o.JwksUri 24 + 25 + w.Header().Set("Content-Type", "application/json") 26 + if err := json.NewEncoder(w).Encode(doc); err != nil { 27 + http.Error(w, err.Error(), http.StatusInternalServerError) 28 + return 29 + } 30 + } 31 + 32 + func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 33 + jwks := o.Config.OAuth.Jwks 34 + pubKey, err := pubKeyFromJwk(jwks) 35 + if err != nil { 36 + log.Printf("error parsing public key: %v", err) 37 + http.Error(w, err.Error(), http.StatusInternalServerError) 38 + return 39 + } 40 + 41 + response := map[string]any{ 42 + "keys": []jwk.Key{pubKey}, 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + w.WriteHeader(http.StatusOK) 47 + json.NewEncoder(w).Encode(response) 48 + } 49 + 50 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 51 + ctx := r.Context() 52 + 53 + sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 54 + if err != nil { 55 + http.Error(w, err.Error(), http.StatusInternalServerError) 56 + return 57 + } 58 + 59 + if err := o.SaveSession(w, r, sessData); err != nil { 60 + http.Error(w, err.Error(), http.StatusInternalServerError) 61 + return 62 + } 63 + 64 + http.Redirect(w, r, "/", http.StatusFound) 65 + }
+108 -203
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 - "log" 6 6 "net/http" 7 - "net/url" 8 7 "time" 9 8 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + atpclient "github.com/bluesky-social/indigo/atproto/client" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + xrpc "github.com/bluesky-social/indigo/xrpc" 11 14 "github.com/gorilla/sessions" 12 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 13 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 14 - sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 15 - "tangled.sh/tangled.sh/core/appview/config" 16 - "tangled.sh/tangled.sh/core/appview/oauth/client" 17 - xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 16 + "tangled.org/core/appview/config" 18 17 ) 19 18 20 - type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 - } 19 + func New(config *config.Config) (*OAuth, error) { 20 + 21 + var oauthConfig oauth.ClientConfig 22 + var clientUri string 25 23 26 - func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { 27 - return &OAuth{ 28 - store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 29 - config: config, 30 - sess: sess, 24 + if config.Core.Dev { 25 + clientUri = "http://127.0.0.1:3000" 26 + callbackUri := clientUri + "/oauth/callback" 27 + oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) 28 + } else { 29 + clientUri = config.Core.AppviewHost 30 + clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 31 + callbackUri := clientUri + "/oauth/callback" 32 + oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 31 33 } 34 + 35 + jwksUri := clientUri + "/oauth/jwks.json" 36 + 37 + authStore, err := NewRedisStore(config.Redis.ToURL()) 38 + if err != nil { 39 + return nil, err 40 + } 41 + 42 + sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 43 + 44 + return &OAuth{ 45 + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 46 + Config: config, 47 + SessStore: sessStore, 48 + JwksUri: jwksUri, 49 + }, nil 32 50 } 33 51 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 52 + type OAuth struct { 53 + ClientApp *oauth.ClientApp 54 + SessStore *sessions.CookieStore 55 + Config *config.Config 56 + JwksUri string 36 57 } 37 58 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 59 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 39 60 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 61 + userSession, err := o.SessStore.Get(r, SessionName) 41 62 if err != nil { 42 63 return err 43 64 } 44 65 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 66 + userSession.Values[SessionDid] = sessData.AccountDID.String() 67 + userSession.Values[SessionPds] = sessData.HostURL 68 + userSession.Values[SessionId] = sessData.SessionID 48 69 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 70 + return userSession.Save(r, w) 71 + } 72 + 73 + func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 74 + userSession, err := o.SessStore.Get(r, SessionName) 50 75 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 76 + return nil, fmt.Errorf("error getting user session: %w", err) 52 77 } 53 - 54 - // then save the whole thing in the db 55 - session := sessioncache.OAuthSession{ 56 - Did: oreq.Did, 57 - Handle: oreq.Handle, 58 - PdsUrl: oreq.PdsUrl, 59 - DpopAuthserverNonce: oreq.DpopAuthserverNonce, 60 - AuthServerIss: oreq.AuthserverIss, 61 - DpopPrivateJwk: oreq.DpopPrivateJwk, 62 - AccessJwt: oresp.AccessToken, 63 - RefreshJwt: oresp.RefreshToken, 64 - Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), 78 + if userSession.IsNew { 79 + return nil, fmt.Errorf("no session available for user") 65 80 } 66 81 67 - return o.sess.SaveSession(r.Context(), session) 68 - } 69 - 70 - func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 71 - userSession, err := o.store.Get(r, SessionName) 72 - if err != nil || userSession.IsNew { 73 - return fmt.Errorf("error getting user session (or new session?): %w", err) 82 + d := userSession.Values[SessionDid].(string) 83 + sessDid, err := syntax.ParseDID(d) 84 + if err != nil { 85 + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 74 86 } 75 87 76 - did := userSession.Values[SessionDid].(string) 88 + sessId := userSession.Values[SessionId].(string) 77 89 78 - err = o.sess.DeleteSession(r.Context(), did) 90 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 79 91 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 92 + return nil, fmt.Errorf("failed to resume session: %w", err) 81 93 } 82 94 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 95 + return clientSess, nil 86 96 } 87 97 88 - func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) { 89 - userSession, err := o.store.Get(r, SessionName) 90 - if err != nil || userSession.IsNew { 91 - return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 92 - } 93 - 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 96 - 97 - session, err := o.sess.GetSession(r.Context(), did) 98 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 99 + userSession, err := o.SessStore.Get(r, SessionName) 98 100 if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 101 + return fmt.Errorf("error getting user session: %w", err) 102 + } 103 + if userSession.IsNew { 104 + return fmt.Errorf("no session available for user") 100 105 } 101 106 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 107 + d := userSession.Values[SessionDid].(string) 108 + sessDid, err := syntax.ParseDID(d) 103 109 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 110 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 105 111 } 106 - if time.Until(expiry) <= 5*time.Minute { 107 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 - if err != nil { 109 - return nil, false, err 110 - } 111 112 112 - self := o.ClientMetadata() 113 + sessId := userSession.Values[SessionId].(string) 113 114 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 115 + // delete the session 116 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 119 117 120 - if err != nil { 121 - return nil, false, err 122 - } 118 + // remove the cookie 119 + userSession.Options.MaxAge = -1 120 + err2 := o.SessStore.Save(r, w, userSession) 123 121 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 122 + return errors.Join(err1, err2) 123 + } 128 124 129 - newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) 130 - err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry) 131 - if err != nil { 132 - return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) 133 - } 134 - 135 - // update the current session 136 - session.AccessJwt = resp.AccessToken 137 - session.RefreshJwt = resp.RefreshToken 138 - session.DpopAuthserverNonce = resp.DpopAuthserverNonce 139 - session.Expiry = newExpiry 125 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 126 + k, err := jwk.ParseKey([]byte(jwks)) 127 + if err != nil { 128 + return nil, err 129 + } 130 + pubKey, err := k.PublicKey() 131 + if err != nil { 132 + return nil, err 140 133 } 141 - 142 - return session, auth, nil 134 + return pubKey, nil 143 135 } 144 136 145 137 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 138 + Did string 139 + Pds string 149 140 } 150 141 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 142 + func (o *OAuth) GetUser(r *http.Request) *User { 143 + sess, err := o.SessStore.Get(r, SessionName) 153 144 154 - if err != nil || clientSession.IsNew { 145 + if err != nil || sess.IsNew { 155 146 return nil 156 147 } 157 148 158 149 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 150 + Did: sess.Values[SessionDid].(string), 151 + Pds: sess.Values[SessionPds].(string), 162 152 } 163 153 } 164 154 165 - func (a *OAuth) GetDid(r *http.Request) string { 166 - clientSession, err := a.store.Get(r, SessionName) 167 - 168 - if err != nil || clientSession.IsNew { 169 - return "" 155 + func (o *OAuth) GetDid(r *http.Request) string { 156 + if u := o.GetUser(r); u != nil { 157 + return u.Did 170 158 } 171 159 172 - return clientSession.Values[SessionDid].(string) 160 + return "" 173 161 } 174 162 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 163 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 164 + session, err := o.ResumeSession(r) 177 165 if err != nil { 178 166 return nil, fmt.Errorf("error getting session: %w", err) 179 167 } 180 - if !auth { 181 - return nil, fmt.Errorf("not authorized") 182 - } 183 - 184 - client := &oauth.XrpcClient{ 185 - OnDpopPdsNonceChanged: func(did, newNonce string) { 186 - err := o.sess.UpdateNonce(r.Context(), did, newNonce) 187 - if err != nil { 188 - log.Printf("error updating dpop pds nonce: %v", err) 189 - } 190 - }, 191 - } 192 - 193 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 194 - if err != nil { 195 - return nil, fmt.Errorf("error parsing private jwk: %w", err) 196 - } 197 - 198 - xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ 199 - Did: session.Did, 200 - PdsUrl: session.PdsUrl, 201 - DpopPdsNonce: session.PdsUrl, 202 - AccessToken: session.AccessJwt, 203 - Issuer: session.AuthServerIss, 204 - DpopPrivateJwk: privateJwk, 205 - }) 206 - 207 - return xrpcClient, nil 168 + return session.APIClient(), nil 208 169 } 209 170 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 171 // this is a higher level abstraction on ServerGetServiceAuth 213 172 type ServiceClientOpts struct { 214 173 service string ··· 259 218 return scheme + s.service 260 219 } 261 220 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 221 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 263 222 opts := ServiceClientOpts{} 264 223 for _, o := range os { 265 224 o(&opts) 266 225 } 267 226 268 - authorizedClient, err := o.AuthorizedClient(r) 227 + client, err := o.AuthorizedClient(r) 269 228 if err != nil { 270 229 return nil, err 271 230 } ··· 276 235 opts.exp = sixty 277 236 } 278 237 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 238 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 280 239 if err != nil { 281 240 return nil, err 282 241 } 283 242 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 243 + return &xrpc.Client{ 244 + Auth: &xrpc.AuthInfo{ 286 245 AccessJwt: resp.Token, 287 246 }, 288 247 Host: opts.Host(), ··· 291 250 }, 292 251 }, nil 293 252 } 294 - 295 - type ClientMetadata struct { 296 - ClientID string `json:"client_id"` 297 - ClientName string `json:"client_name"` 298 - SubjectType string `json:"subject_type"` 299 - ClientURI string `json:"client_uri"` 300 - RedirectURIs []string `json:"redirect_uris"` 301 - GrantTypes []string `json:"grant_types"` 302 - ResponseTypes []string `json:"response_types"` 303 - ApplicationType string `json:"application_type"` 304 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 - JwksURI string `json:"jwks_uri"` 306 - Scope string `json:"scope"` 307 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 - } 310 - 311 - func (o *OAuth) ClientMetadata() ClientMetadata { 312 - makeRedirectURIs := func(c string) []string { 313 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 - } 315 - 316 - clientURI := o.config.Core.AppviewHost 317 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 - redirectURIs := makeRedirectURIs(clientURI) 319 - 320 - if o.config.Core.Dev { 321 - clientURI = "http://127.0.0.1:3000" 322 - redirectURIs = makeRedirectURIs(clientURI) 323 - 324 - query := url.Values{} 325 - query.Add("redirect_uri", redirectURIs[0]) 326 - query.Add("scope", "atproto transition:generic") 327 - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 - } 329 - 330 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 - 332 - return ClientMetadata{ 333 - ClientID: clientID, 334 - ClientName: "Tangled", 335 - SubjectType: "public", 336 - ClientURI: clientURI, 337 - RedirectURIs: redirectURIs, 338 - GrantTypes: []string{"authorization_code", "refresh_token"}, 339 - ResponseTypes: []string{"code"}, 340 - ApplicationType: "web", 341 - DpopBoundAccessTokens: true, 342 - JwksURI: jwksURI, 343 - Scope: "atproto transition:generic", 344 - TokenEndpointAuthMethod: "private_key_jwt", 345 - TokenEndpointAuthSigningAlg: "ES256", 346 - } 347 - }
+147
appview/oauth/store.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/redis/go-redis/v9" 12 + ) 13 + 14 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+35
appview/pages/cache.go
··· 1 + package pages 2 + 3 + import ( 4 + "sync" 5 + ) 6 + 7 + type TmplCache[K comparable, V any] struct { 8 + data map[K]V 9 + mutex sync.RWMutex 10 + } 11 + 12 + func NewTmplCache[K comparable, V any]() *TmplCache[K, V] { 13 + return &TmplCache[K, V]{ 14 + data: make(map[K]V), 15 + } 16 + } 17 + 18 + func (c *TmplCache[K, V]) Get(key K) (V, bool) { 19 + c.mutex.RLock() 20 + defer c.mutex.RUnlock() 21 + val, exists := c.data[key] 22 + return val, exists 23 + } 24 + 25 + func (c *TmplCache[K, V]) Set(key K, value V) { 26 + c.mutex.Lock() 27 + defer c.mutex.Unlock() 28 + c.data[key] = value 29 + } 30 + 31 + func (c *TmplCache[K, V]) Size() int { 32 + c.mutex.RLock() 33 + defer c.mutex.RUnlock() 34 + return len(c.data) 35 + }
+42 -25
appview/pages/funcmap.go
··· 19 19 20 20 "github.com/dustin/go-humanize" 21 21 "github.com/go-enry/go-enry/v2" 22 - "tangled.sh/tangled.sh/core/appview/filetree" 23 - "tangled.sh/tangled.sh/core/appview/pages/markup" 24 - "tangled.sh/tangled.sh/core/crypto" 22 + "tangled.org/core/appview/filetree" 23 + "tangled.org/core/appview/pages/markup" 24 + "tangled.org/core/crypto" 25 25 ) 26 26 27 27 func (p *Pages) funcMap() template.FuncMap { 28 28 return template.FuncMap{ 29 29 "split": func(s string) []string { 30 30 return strings.Split(s, "\n") 31 + }, 32 + "trimPrefix": func(s, prefix string) string { 33 + return strings.TrimPrefix(s, prefix) 34 + }, 35 + "join": func(elems []string, sep string) string { 36 + return strings.Join(elems, sep) 37 + }, 38 + "contains": func(s string, target string) bool { 39 + return strings.Contains(s, target) 40 + }, 41 + "mapContains": func(m any, key any) bool { 42 + mapValue := reflect.ValueOf(m) 43 + if mapValue.Kind() != reflect.Map { 44 + return false 45 + } 46 + keyValue := reflect.ValueOf(key) 47 + return mapValue.MapIndex(keyValue).IsValid() 31 48 }, 32 49 "resolve": func(s string) string { 33 50 identity, err := p.resolver.ResolveIdent(context.Background(), s) ··· 124 141 "relTimeFmt": humanize.Time, 125 142 "shortRelTimeFmt": func(t time.Time) string { 126 143 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 127 - {time.Second, "now", time.Second}, 128 - {2 * time.Second, "1s %s", 1}, 129 - {time.Minute, "%ds %s", time.Second}, 130 - {2 * time.Minute, "1min %s", 1}, 131 - {time.Hour, "%dmin %s", time.Minute}, 132 - {2 * time.Hour, "1hr %s", 1}, 133 - {humanize.Day, "%dhrs %s", time.Hour}, 134 - {2 * humanize.Day, "1d %s", 1}, 135 - {20 * humanize.Day, "%dd %s", humanize.Day}, 136 - {8 * humanize.Week, "%dw %s", humanize.Week}, 137 - {humanize.Year, "%dmo %s", humanize.Month}, 138 - {18 * humanize.Month, "1y %s", 1}, 139 - {2 * humanize.Year, "2y %s", 1}, 140 - {humanize.LongTime, "%dy %s", humanize.Year}, 141 - {math.MaxInt64, "a long while %s", 1}, 144 + {D: time.Second, Format: "now", DivBy: time.Second}, 145 + {D: 2 * time.Second, Format: "1s %s", DivBy: 1}, 146 + {D: time.Minute, Format: "%ds %s", DivBy: time.Second}, 147 + {D: 2 * time.Minute, Format: "1min %s", DivBy: 1}, 148 + {D: time.Hour, Format: "%dmin %s", DivBy: time.Minute}, 149 + {D: 2 * time.Hour, Format: "1hr %s", DivBy: 1}, 150 + {D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour}, 151 + {D: 2 * humanize.Day, Format: "1d %s", DivBy: 1}, 152 + {D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day}, 153 + {D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week}, 154 + {D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month}, 155 + {D: 18 * humanize.Month, Format: "1y %s", DivBy: 1}, 156 + {D: 2 * humanize.Year, Format: "2y %s", DivBy: 1}, 157 + {D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year}, 158 + {D: math.MaxInt64, Format: "a long while %s", DivBy: 1}, 142 159 }) 143 160 }, 144 161 "longTimeFmt": func(t time.Time) string { ··· 248 265 return nil 249 266 }, 250 267 "i": func(name string, classes ...string) template.HTML { 251 - data, err := icon(name, classes) 268 + data, err := p.icon(name, classes) 252 269 if err != nil { 253 270 log.Printf("icon %s does not exist", name) 254 - data, _ = icon("airplay", classes) 271 + data, _ = p.icon("airplay", classes) 255 272 } 256 273 return template.HTML(data) 257 274 }, 258 - "cssContentHash": CssContentHash, 275 + "cssContentHash": p.CssContentHash, 259 276 "fileTree": filetree.FileTree, 260 277 "pathEscape": func(s string) string { 261 278 return url.PathEscape(s) ··· 266 283 }, 267 284 268 285 "tinyAvatar": func(handle string) string { 269 - return p.avatarUri(handle, "tiny") 286 + return p.AvatarUrl(handle, "tiny") 270 287 }, 271 288 "fullAvatar": func(handle string) string { 272 - return p.avatarUri(handle, "") 289 + return p.AvatarUrl(handle, "") 273 290 }, 274 291 "langColor": enry.GetColor, 275 292 "layoutSide": func() string { ··· 293 310 } 294 311 } 295 312 296 - func (p *Pages) avatarUri(handle, size string) string { 313 + func (p *Pages) AvatarUrl(handle, size string) string { 297 314 handle = strings.TrimPrefix(handle, "@") 298 315 299 316 secret := p.avatar.SharedSecret ··· 308 325 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 309 326 } 310 327 311 - func icon(name string, classes []string) (template.HTML, error) { 328 + func (p *Pages) icon(name string, classes []string) (template.HTML, error) { 312 329 iconPath := filepath.Join("static", "icons", name) 313 330 314 331 if filepath.Ext(name) == "" {
+30
appview/pages/funcmap_test.go
··· 1 + package pages 2 + 3 + import ( 4 + "html/template" 5 + "tangled.org/core/appview/config" 6 + "tangled.org/core/idresolver" 7 + "testing" 8 + ) 9 + 10 + func TestPages_funcMap(t *testing.T) { 11 + tests := []struct { 12 + name string // description of this test case 13 + // Named input parameters for receiver constructor. 14 + config *config.Config 15 + res *idresolver.Resolver 16 + want template.FuncMap 17 + }{ 18 + // TODO: Add test cases. 19 + } 20 + for _, tt := range tests { 21 + t.Run(tt.name, func(t *testing.T) { 22 + p := NewPages(tt.config, tt.res) 23 + got := p.funcMap() 24 + // TODO: update the condition below to compare got with tt.want. 25 + if true { 26 + t.Errorf("funcMap() = %v, want %v", got, tt.want) 27 + } 28 + }) 29 + } 30 + }
+156
appview/pages/legal/privacy.md
··· 1 + **Last updated:** September 26, 2025 2 + 3 + This Privacy Policy describes how Tangled ("we," "us," or "our") 4 + collects, uses, and shares your personal information when you use our 5 + platform and services (the "Service"). 6 + 7 + ## 1. Information We Collect 8 + 9 + ### Account Information 10 + 11 + When you create an account, we collect: 12 + 13 + - Your chosen username 14 + - Email address 15 + - Profile information you choose to provide 16 + - Authentication data 17 + 18 + ### Content and Activity 19 + 20 + We store: 21 + 22 + - Code repositories and associated metadata 23 + - Issues, pull requests, and comments 24 + - Activity logs and usage patterns 25 + - Public keys for authentication 26 + 27 + ## 2. Data Location and Hosting 28 + 29 + ### EU Data Hosting 30 + 31 + **All Tangled service data is hosted within the European Union.** 32 + Specifically: 33 + 34 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 35 + (*.tngl.sh) are located in Finland 36 + - **Application Data:** All other service data is stored on EU-based 37 + servers 38 + - **Data Processing:** All data processing occurs within EU 39 + jurisdiction 40 + 41 + ### External PDS Notice 42 + 43 + **Important:** If your account is hosted on Bluesky's PDS or other 44 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 45 + that data. The data protection, storage location, and privacy 46 + practices for such accounts are governed by the respective PDS 47 + provider's policies, not this Privacy Policy. We only control data 48 + processing within our own services and infrastructure. 49 + 50 + ## 3. Third-Party Data Processors 51 + 52 + We only share your data with the following third-party processors: 53 + 54 + ### Resend (Email Services) 55 + 56 + - **Purpose:** Sending transactional emails (account verification, 57 + notifications) 58 + - **Data Shared:** Email address and necessary message content 59 + 60 + ### Cloudflare (Image Caching) 61 + 62 + - **Purpose:** Caching and optimizing image delivery 63 + - **Data Shared:** Public images and associated metadata for caching 64 + purposes 65 + 66 + ### Posthog (Usage Metrics Tracking) 67 + 68 + - **Purpose:** Tracking usage and platform metrics 69 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 70 + information 71 + 72 + ## 4. How We Use Your Information 73 + 74 + We use your information to: 75 + 76 + - Provide and maintain the Service 77 + - Process your transactions and requests 78 + - Send you technical notices and support messages 79 + - Improve and develop new features 80 + - Ensure security and prevent fraud 81 + - Comply with legal obligations 82 + 83 + ## 5. Data Sharing and Disclosure 84 + 85 + We do not sell, trade, or rent your personal information. We may share 86 + your information only in the following circumstances: 87 + 88 + - With the third-party processors listed above 89 + - When required by law or legal process 90 + - To protect our rights, property, or safety, or that of our users 91 + - In connection with a merger, acquisition, or sale of assets (with 92 + appropriate protections) 93 + 94 + ## 6. Data Security 95 + 96 + We implement appropriate technical and organizational measures to 97 + protect your personal information against unauthorized access, 98 + alteration, disclosure, or destruction. However, no method of 99 + transmission over the Internet is 100% secure. 100 + 101 + ## 7. Data Retention 102 + 103 + We retain your personal information for as long as necessary to provide 104 + the Service and fulfill the purposes outlined in this Privacy Policy, 105 + unless a longer retention period is required by law. 106 + 107 + ## 8. Your Rights 108 + 109 + Under applicable data protection laws, you have the right to: 110 + 111 + - Access your personal information 112 + - Correct inaccurate information 113 + - Request deletion of your information 114 + - Object to processing of your information 115 + - Data portability 116 + - Withdraw consent (where applicable) 117 + 118 + ## 9. Cookies and Tracking 119 + 120 + We use cookies and similar technologies to: 121 + 122 + - Maintain your login session 123 + - Remember your preferences 124 + - Analyze usage patterns to improve the Service 125 + 126 + You can control cookie settings through your browser preferences. 127 + 128 + ## 10. Children's Privacy 129 + 130 + The Service is not intended for children under 16 years of age. We do 131 + not knowingly collect personal information from children under 16. If 132 + we become aware that we have collected such information, we will take 133 + steps to delete it. 134 + 135 + ## 11. International Data Transfers 136 + 137 + While all our primary data processing occurs within the EU, some of our 138 + third-party processors may process data outside the EU. When this 139 + occurs, we ensure appropriate safeguards are in place, such as Standard 140 + Contractual Clauses or adequacy decisions. 141 + 142 + ## 12. Changes to This Privacy Policy 143 + 144 + We may update this Privacy Policy from time to time. We will notify you 145 + of any changes by posting the new Privacy Policy on this page and 146 + updating the "Last updated" date. 147 + 148 + ## 13. Contact Information 149 + 150 + If you have any questions about this Privacy Policy or wish to exercise 151 + your rights, please contact us through our platform or via email. 152 + 153 + --- 154 + 155 + This Privacy Policy complies with the EU General Data Protection 156 + Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
··· 1 + **Last updated:** September 26, 2025 2 + 3 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 4 + to and use of the Tangled platform and services (the "Service") 5 + operated by us ("Tangled," "we," "us," or "our"). 6 + 7 + ## 1. Acceptance of Terms 8 + 9 + By accessing or using our Service, you agree to be bound by these Terms. 10 + If you disagree with any part of these terms, then you may not access 11 + the Service. 12 + 13 + ## 2. Account Registration 14 + 15 + To use certain features of the Service, you must register for an 16 + account. You agree to provide accurate, current, and complete 17 + information during the registration process and to update such 18 + information to keep it accurate, current, and complete. 19 + 20 + ## 3. Account Termination 21 + 22 + > **Important Notice** 23 + > 24 + > **We reserve the right to terminate, suspend, or restrict access to 25 + > your account at any time, for any reason, or for no reason at all, at 26 + > our sole discretion.** This includes, but is not limited to, 27 + > termination for violation of these Terms, inappropriate conduct, spam, 28 + > abuse, or any other behavior we deem harmful to the Service or other 29 + > users. 30 + > 31 + > Account termination may result in the loss of access to your 32 + > repositories, data, and other content associated with your account. We 33 + > are not obligated to provide advance notice of termination, though we 34 + > may do so in our discretion. 35 + 36 + ## 4. Acceptable Use 37 + 38 + You agree not to use the Service to: 39 + 40 + - Violate any applicable laws or regulations 41 + - Infringe upon the rights of others 42 + - Upload, store, or share content that is illegal, harmful, threatening, 43 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 44 + objectionable 45 + - Engage in spam, phishing, or other deceptive practices 46 + - Attempt to gain unauthorized access to the Service or other users' 47 + accounts 48 + - Interfere with or disrupt the Service or servers connected to the 49 + Service 50 + 51 + ## 5. Content and Intellectual Property 52 + 53 + You retain ownership of the content you upload to the Service. By 54 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 55 + license to use, reproduce, modify, and distribute your content as 56 + necessary to provide the Service. 57 + 58 + ## 6. Privacy 59 + 60 + Your privacy is important to us. Please review our [Privacy 61 + Policy](/privacy), which also governs your use of the Service. 62 + 63 + ## 7. Disclaimers 64 + 65 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 66 + no warranties, expressed or implied, and hereby disclaim and negate all 67 + other warranties including without limitation, implied warranties or 68 + conditions of merchantability, fitness for a particular purpose, or 69 + non-infringement of intellectual property or other violation of rights. 70 + 71 + ## 8. Limitation of Liability 72 + 73 + In no event shall Tangled, nor its directors, employees, partners, 74 + agents, suppliers, or affiliates, be liable for any indirect, 75 + incidental, special, consequential, or punitive damages, including 76 + without limitation, loss of profits, data, use, goodwill, or other 77 + intangible losses, resulting from your use of the Service. 78 + 79 + ## 9. Indemnification 80 + 81 + You agree to defend, indemnify, and hold harmless Tangled and its 82 + affiliates, officers, directors, employees, and agents from and against 83 + any and all claims, damages, obligations, losses, liabilities, costs, 84 + or debt, and expenses (including attorney's fees). 85 + 86 + ## 10. Governing Law 87 + 88 + These Terms shall be interpreted and governed by the laws of Finland, 89 + without regard to its conflict of law provisions. 90 + 91 + ## 11. Changes to Terms 92 + 93 + We reserve the right to modify or replace these Terms at any time. If a 94 + revision is material, we will try to provide at least 30 days notice 95 + prior to any new terms taking effect. 96 + 97 + ## 12. Contact Information 98 + 99 + If you have any questions about these Terms of Service, please contact 100 + us through our platform or via email. 101 + 102 + --- 103 + 104 + These terms are effective as of the last updated date shown above and 105 + will remain in effect except with respect to any changes in their 106 + provisions in the future, which will be in effect immediately after 107 + being posted on this page.
+17 -7
appview/pages/markup/format.go
··· 1 1 package markup 2 2 3 - import "strings" 3 + import ( 4 + "regexp" 5 + ) 4 6 5 7 type Format string 6 8 ··· 10 12 ) 11 13 12 14 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 16 + } 17 + 18 + var FileTypePatterns = map[Format]*regexp.Regexp{ 19 + FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`), 20 + } 21 + 22 + var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`) 23 + 24 + func IsReadmeFile(filename string) bool { 25 + return ReadmePattern.MatchString(filename) 14 26 } 15 27 16 28 func GetFormat(filename string) Format { 17 - for format, extensions := range FileTypes { 18 - for _, extension := range extensions { 19 - if strings.HasSuffix(filename, extension) { 20 - return format 21 - } 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 22 32 } 23 33 } 24 34 // default format
+17 -10
appview/pages/markup/markdown.go
··· 5 5 "bytes" 6 6 "fmt" 7 7 "io" 8 + "io/fs" 8 9 "net/url" 9 10 "path" 10 11 "strings" ··· 20 21 "github.com/yuin/goldmark/renderer/html" 21 22 "github.com/yuin/goldmark/text" 22 23 "github.com/yuin/goldmark/util" 24 + callout "gitlab.com/staticnoise/goldmark-callout" 23 25 htmlparse "golang.org/x/net/html" 24 26 25 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 27 + "tangled.org/core/api/tangled" 28 + "tangled.org/core/appview/pages/repoinfo" 26 29 ) 27 30 28 31 // RendererType defines the type of renderer to use based on context ··· 44 47 IsDev bool 45 48 RendererType RendererType 46 49 Sanitizer Sanitizer 50 + Files fs.FS 47 51 } 48 52 49 53 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 61 65 extension.WithFootnoteIDPrefix([]byte("footnote")), 62 66 ), 63 67 treeblood.MathML(), 68 + callout.CalloutExtention, 64 69 ), 65 70 goldmark.WithParserOptions( 66 71 parser.WithAutoHeadingID(), ··· 139 144 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 140 145 switch node.Type { 141 146 case htmlparse.ElementNode: 142 - if node.Data == "img" || node.Data == "source" { 147 + switch node.Data { 148 + case "img", "source": 143 149 for i, attr := range node.Attr { 144 150 if attr.Key != "src" { 145 151 continue ··· 231 237 232 238 actualPath := rctx.actualPath(dst) 233 239 240 + repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 241 + 242 + query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 243 + url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath) 244 + 234 245 parsedURL := &url.URL{ 235 - Scheme: scheme, 236 - Host: rctx.Knot, 237 - Path: path.Join("/", 238 - rctx.RepoInfo.OwnerDid, 239 - rctx.RepoInfo.Name, 240 - "raw", 241 - url.PathEscape(rctx.RepoInfo.Ref), 242 - actualPath), 246 + Scheme: scheme, 247 + Host: rctx.Knot, 248 + Path: path.Join("/xrpc", tangled.RepoBlobNSID), 249 + RawQuery: query, 243 250 } 244 251 newPath := parsedURL.String() 245 252 return newPath
+3
appview/pages/markup/sanitizer.go
··· 114 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 116 117 + // goldmark-callout 118 + policy.AllowAttrs("data-callout").OnElements("details") 119 + 117 120 return policy 118 121 } 119 122
+509 -297
appview/pages/pages.go
··· 9 9 "html/template" 10 10 "io" 11 11 "io/fs" 12 - "log" 12 + "log/slog" 13 13 "net/http" 14 14 "os" 15 15 "path/filepath" 16 16 "strings" 17 17 "sync" 18 18 19 - "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview/commitverify" 21 - "tangled.sh/tangled.sh/core/appview/config" 22 - "tangled.sh/tangled.sh/core/appview/db" 23 - "tangled.sh/tangled.sh/core/appview/oauth" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 26 - "tangled.sh/tangled.sh/core/appview/pagination" 27 - "tangled.sh/tangled.sh/core/idresolver" 28 - "tangled.sh/tangled.sh/core/patchutil" 29 - "tangled.sh/tangled.sh/core/types" 19 + "tangled.org/core/api/tangled" 20 + "tangled.org/core/appview/commitverify" 21 + "tangled.org/core/appview/config" 22 + "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/oauth" 24 + "tangled.org/core/appview/pages/markup" 25 + "tangled.org/core/appview/pages/repoinfo" 26 + "tangled.org/core/appview/pagination" 27 + "tangled.org/core/idresolver" 28 + "tangled.org/core/patchutil" 29 + "tangled.org/core/types" 30 30 31 31 "github.com/alecthomas/chroma/v2" 32 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" ··· 38 38 "github.com/go-git/go-git/v5/plumbing/object" 39 39 ) 40 40 41 - //go:embed templates/* static 41 + //go:embed templates/* static legal 42 42 var Files embed.FS 43 43 44 44 type Pages struct { 45 - mu sync.RWMutex 46 - t map[string]*template.Template 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 47 47 48 48 avatar config.AvatarConfig 49 49 resolver *idresolver.Resolver 50 50 dev bool 51 - embedFS embed.FS 51 + embedFS fs.FS 52 52 templateDir string // Path to templates on disk for dev mode 53 53 rctx *markup.RenderContext 54 + logger *slog.Logger 54 55 } 55 56 56 57 func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { ··· 60 61 CamoUrl: config.Camo.Host, 61 62 CamoSecret: config.Camo.SharedSecret, 62 63 Sanitizer: markup.NewSanitizer(), 64 + Files: Files, 63 65 } 64 66 65 67 p := &Pages{ 66 68 mu: sync.RWMutex{}, 67 - t: make(map[string]*template.Template), 69 + cache: NewTmplCache[string, *template.Template](), 68 70 dev: config.Core.Dev, 69 71 avatar: config.Avatar, 70 - embedFS: Files, 71 72 rctx: rctx, 72 73 resolver: res, 73 74 templateDir: "appview/pages", 75 + logger: slog.Default().With("component", "pages"), 74 76 } 75 77 76 - // Initial load of all templates 77 - p.loadAllTemplates() 78 + if p.dev { 79 + p.embedFS = os.DirFS(p.templateDir) 80 + } else { 81 + p.embedFS = Files 82 + } 78 83 79 84 return p 80 85 } 81 86 82 - func (p *Pages) loadAllTemplates() { 83 - templates := make(map[string]*template.Template) 87 + // reverse of pathToName 88 + func (p *Pages) nameToPath(s string) string { 89 + return "templates/" + s + ".html" 90 + } 91 + 92 + func (p *Pages) fragmentPaths() ([]string, error) { 84 93 var fragmentPaths []string 85 - 86 - // Use embedded FS for initial loading 87 - // First, collect all fragment paths 88 94 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 89 95 if err != nil { 90 96 return err ··· 98 104 if !strings.Contains(path, "fragments/") { 99 105 return nil 100 106 } 101 - name := strings.TrimPrefix(path, "templates/") 102 - name = strings.TrimSuffix(name, ".html") 103 - tmpl, err := template.New(name). 104 - Funcs(p.funcMap()). 105 - ParseFS(p.embedFS, path) 106 - if err != nil { 107 - log.Fatalf("setting up fragment: %v", err) 108 - } 109 - templates[name] = tmpl 110 107 fragmentPaths = append(fragmentPaths, path) 111 - log.Printf("loaded fragment: %s", name) 112 108 return nil 113 109 }) 114 110 if err != nil { 115 - log.Fatalf("walking template dir for fragments: %v", err) 111 + return nil, err 116 112 } 117 113 118 - // Then walk through and setup the rest of the templates 119 - err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 120 - if err != nil { 121 - return err 122 - } 123 - if d.IsDir() { 124 - return nil 125 - } 126 - if !strings.HasSuffix(path, "html") { 127 - return nil 128 - } 129 - // Skip fragments as they've already been loaded 130 - if strings.Contains(path, "fragments/") { 131 - return nil 132 - } 133 - // Skip layouts 134 - if strings.Contains(path, "layouts/") { 135 - return nil 136 - } 137 - name := strings.TrimPrefix(path, "templates/") 138 - name = strings.TrimSuffix(name, ".html") 139 - // Add the page template on top of the base 140 - allPaths := []string{} 141 - allPaths = append(allPaths, "templates/layouts/*.html") 142 - allPaths = append(allPaths, fragmentPaths...) 143 - allPaths = append(allPaths, path) 144 - tmpl, err := template.New(name). 145 - Funcs(p.funcMap()). 146 - ParseFS(p.embedFS, allPaths...) 147 - if err != nil { 148 - return fmt.Errorf("setting up template: %w", err) 149 - } 150 - templates[name] = tmpl 151 - log.Printf("loaded template: %s", name) 152 - return nil 153 - }) 114 + return fragmentPaths, nil 115 + } 116 + 117 + // parse without memoization 118 + func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 119 + paths, err := p.fragmentPaths() 154 120 if err != nil { 155 - log.Fatalf("walking template dir: %v", err) 121 + return nil, err 122 + } 123 + for _, s := range stack { 124 + paths = append(paths, p.nameToPath(s)) 156 125 } 157 126 158 - log.Printf("total templates loaded: %d", len(templates)) 159 - p.mu.Lock() 160 - defer p.mu.Unlock() 161 - p.t = templates 127 + funcs := p.funcMap() 128 + top := stack[len(stack)-1] 129 + parsed, err := template.New(top). 130 + Funcs(funcs). 131 + ParseFS(p.embedFS, paths...) 132 + if err != nil { 133 + return nil, err 134 + } 135 + 136 + return parsed, nil 162 137 } 163 138 164 - // loadTemplateFromDisk loads a template from the filesystem in dev mode 165 - func (p *Pages) loadTemplateFromDisk(name string) error { 166 - if !p.dev { 167 - return nil 139 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 140 + key := strings.Join(stack, "|") 141 + 142 + // never cache in dev mode 143 + if cached, exists := p.cache.Get(key); !p.dev && exists { 144 + return cached, nil 168 145 } 169 146 170 - log.Printf("reloading template from disk: %s", name) 171 - 172 - // Find all fragments first 173 - var fragmentPaths []string 174 - err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 175 - if err != nil { 176 - return err 177 - } 178 - if d.IsDir() { 179 - return nil 180 - } 181 - if !strings.HasSuffix(path, ".html") { 182 - return nil 183 - } 184 - if !strings.Contains(path, "fragments/") { 185 - return nil 186 - } 187 - fragmentPaths = append(fragmentPaths, path) 188 - return nil 189 - }) 147 + result, err := p.rawParse(stack...) 190 148 if err != nil { 191 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 149 + return nil, err 192 150 } 193 151 194 - // Find the template path on disk 195 - templatePath := filepath.Join(p.templateDir, "templates", name+".html") 196 - if _, err := os.Stat(templatePath); os.IsNotExist(err) { 197 - return fmt.Errorf("template not found on disk: %s", name) 152 + p.cache.Set(key, result) 153 + return result, nil 154 + } 155 + 156 + func (p *Pages) parseBase(top string) (*template.Template, error) { 157 + stack := []string{ 158 + "layouts/base", 159 + top, 198 160 } 161 + return p.parse(stack...) 162 + } 199 163 200 - // Create a new template 201 - tmpl := template.New(name).Funcs(p.funcMap()) 164 + func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 165 + stack := []string{ 166 + "layouts/base", 167 + "layouts/repobase", 168 + top, 169 + } 170 + return p.parse(stack...) 171 + } 202 172 203 - // Parse layouts 204 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 205 - layouts, err := filepath.Glob(layoutGlob) 206 - if err != nil { 207 - return fmt.Errorf("finding layout templates: %w", err) 173 + func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 174 + stack := []string{ 175 + "layouts/base", 176 + "layouts/profilebase", 177 + top, 208 178 } 179 + return p.parse(stack...) 180 + } 209 181 210 - // Create paths for parsing 211 - allFiles := append(layouts, fragmentPaths...) 212 - allFiles = append(allFiles, templatePath) 213 - 214 - // Parse all templates 215 - tmpl, err = tmpl.ParseFiles(allFiles...) 182 + func (p *Pages) executePlain(name string, w io.Writer, params any) error { 183 + tpl, err := p.parse(name) 216 184 if err != nil { 217 - return fmt.Errorf("parsing template files: %w", err) 185 + return err 218 186 } 219 187 220 - // Update the template in the map 221 - p.mu.Lock() 222 - defer p.mu.Unlock() 223 - p.t[name] = tmpl 224 - log.Printf("template reloaded from disk: %s", name) 225 - return nil 188 + return tpl.Execute(w, params) 226 189 } 227 190 228 - func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 229 - // In dev mode, reload the template from disk before executing 230 - if p.dev { 231 - if err := p.loadTemplateFromDisk(templateName); err != nil { 232 - log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 233 - // Continue with the existing template 234 - } 191 + func (p *Pages) execute(name string, w io.Writer, params any) error { 192 + tpl, err := p.parseBase(name) 193 + if err != nil { 194 + return err 235 195 } 236 196 237 - p.mu.RLock() 238 - defer p.mu.RUnlock() 239 - tmpl, exists := p.t[templateName] 240 - if !exists { 241 - return fmt.Errorf("template not found: %s", templateName) 242 - } 197 + return tpl.ExecuteTemplate(w, "layouts/base", params) 198 + } 243 199 244 - if base == "" { 245 - return tmpl.Execute(w, params) 246 - } else { 247 - return tmpl.ExecuteTemplate(w, base, params) 200 + func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 201 + tpl, err := p.parseRepoBase(name) 202 + if err != nil { 203 + return err 248 204 } 249 - } 250 205 251 - func (p *Pages) execute(name string, w io.Writer, params any) error { 252 - return p.executeOrReload(name, w, "layouts/base", params) 206 + return tpl.ExecuteTemplate(w, "layouts/base", params) 253 207 } 254 208 255 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 256 - return p.executeOrReload(name, w, "", params) 257 - } 209 + func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 210 + tpl, err := p.parseProfileBase(name) 211 + if err != nil { 212 + return err 213 + } 258 214 259 - func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 260 - return p.executeOrReload(name, w, "layouts/repobase", params) 215 + return tpl.ExecuteTemplate(w, "layouts/base", params) 261 216 } 262 217 263 218 func (p *Pages) Favicon(w io.Writer) error { 264 - return p.executePlain("favicon", w, nil) 219 + return p.executePlain("fragments/dolly/silhouette", w, nil) 265 220 } 266 221 267 222 type LoginParams struct { ··· 272 227 return p.executePlain("user/login", w, params) 273 228 } 274 229 275 - func (p *Pages) Signup(w io.Writer) error { 276 - return p.executePlain("user/signup", w, nil) 230 + type SignupParams struct { 231 + CloudflareSiteKey string 232 + } 233 + 234 + func (p *Pages) Signup(w io.Writer, params SignupParams) error { 235 + return p.executePlain("user/signup", w, params) 277 236 } 278 237 279 238 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 282 241 283 242 type TermsOfServiceParams struct { 284 243 LoggedInUser *oauth.User 244 + Content template.HTML 285 245 } 286 246 287 247 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 248 + filename := "terms.md" 249 + filePath := filepath.Join("legal", filename) 250 + 251 + file, err := p.embedFS.Open(filePath) 252 + if err != nil { 253 + return fmt.Errorf("failed to read %s: %w", filename, err) 254 + } 255 + defer file.Close() 256 + 257 + markdownBytes, err := io.ReadAll(file) 258 + if err != nil { 259 + return fmt.Errorf("failed to read %s: %w", filename, err) 260 + } 261 + 262 + p.rctx.RendererType = markup.RendererTypeDefault 263 + htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 264 + sanitized := p.rctx.SanitizeDefault(htmlString) 265 + params.Content = template.HTML(sanitized) 266 + 288 267 return p.execute("legal/terms", w, params) 289 268 } 290 269 291 270 type PrivacyPolicyParams struct { 292 271 LoggedInUser *oauth.User 272 + Content template.HTML 293 273 } 294 274 295 275 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 276 + filename := "privacy.md" 277 + filePath := filepath.Join("legal", filename) 278 + 279 + file, err := p.embedFS.Open(filePath) 280 + if err != nil { 281 + return fmt.Errorf("failed to read %s: %w", filename, err) 282 + } 283 + defer file.Close() 284 + 285 + markdownBytes, err := io.ReadAll(file) 286 + if err != nil { 287 + return fmt.Errorf("failed to read %s: %w", filename, err) 288 + } 289 + 290 + p.rctx.RendererType = markup.RendererTypeDefault 291 + htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 292 + sanitized := p.rctx.SanitizeDefault(htmlString) 293 + params.Content = template.HTML(sanitized) 294 + 296 295 return p.execute("legal/privacy", w, params) 297 296 } 298 297 298 + type BrandParams struct { 299 + LoggedInUser *oauth.User 300 + } 301 + 302 + func (p *Pages) Brand(w io.Writer, params BrandParams) error { 303 + return p.execute("brand/brand", w, params) 304 + } 305 + 299 306 type TimelineParams struct { 300 307 LoggedInUser *oauth.User 301 - Timeline []db.TimelineEvent 302 - Repos []db.Repo 308 + Timeline []models.TimelineEvent 309 + Repos []models.Repo 310 + GfiLabel *models.LabelDefinition 303 311 } 304 312 305 313 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 306 314 return p.execute("timeline/timeline", w, params) 307 315 } 308 316 317 + type GoodFirstIssuesParams struct { 318 + LoggedInUser *oauth.User 319 + Issues []models.Issue 320 + RepoGroups []*models.RepoGroup 321 + LabelDefs map[string]*models.LabelDefinition 322 + GfiLabel *models.LabelDefinition 323 + Page pagination.Page 324 + } 325 + 326 + func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 327 + return p.execute("goodfirstissues/index", w, params) 328 + } 329 + 309 330 type UserProfileSettingsParams struct { 310 331 LoggedInUser *oauth.User 311 332 Tabs []map[string]any ··· 316 337 return p.execute("user/settings/profile", w, params) 317 338 } 318 339 340 + type NotificationsParams struct { 341 + LoggedInUser *oauth.User 342 + Notifications []*models.NotificationWithEntity 343 + UnreadCount int 344 + Page pagination.Page 345 + Total int64 346 + } 347 + 348 + func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { 349 + return p.execute("notifications/list", w, params) 350 + } 351 + 352 + type NotificationItemParams struct { 353 + Notification *models.Notification 354 + } 355 + 356 + func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error { 357 + return p.executePlain("notifications/fragments/item", w, params) 358 + } 359 + 360 + type NotificationCountParams struct { 361 + Count int64 362 + } 363 + 364 + func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { 365 + return p.executePlain("notifications/fragments/count", w, params) 366 + } 367 + 319 368 type UserKeysSettingsParams struct { 320 369 LoggedInUser *oauth.User 321 - PubKeys []db.PublicKey 370 + PubKeys []models.PublicKey 322 371 Tabs []map[string]any 323 372 Tab string 324 373 } ··· 329 378 330 379 type UserEmailsSettingsParams struct { 331 380 LoggedInUser *oauth.User 332 - Emails []db.Email 381 + Emails []models.Email 333 382 Tabs []map[string]any 334 383 Tab string 335 384 } ··· 338 387 return p.execute("user/settings/emails", w, params) 339 388 } 340 389 341 - type KnotBannerParams struct { 342 - Registrations []db.Registration 390 + type UserNotificationSettingsParams struct { 391 + LoggedInUser *oauth.User 392 + Preferences *models.NotificationPreferences 393 + Tabs []map[string]any 394 + Tab string 343 395 } 344 396 345 - func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 346 - return p.executePlain("knots/fragments/banner", w, params) 397 + func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error { 398 + return p.execute("user/settings/notifications", w, params) 399 + } 400 + 401 + type UpgradeBannerParams struct { 402 + Registrations []models.Registration 403 + Spindles []models.Spindle 404 + } 405 + 406 + func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 407 + return p.executePlain("banner", w, params) 347 408 } 348 409 349 410 type KnotsParams struct { 350 411 LoggedInUser *oauth.User 351 - Registrations []db.Registration 412 + Registrations []models.Registration 352 413 } 353 414 354 415 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 357 418 358 419 type KnotParams struct { 359 420 LoggedInUser *oauth.User 360 - Registration *db.Registration 421 + Registration *models.Registration 361 422 Members []string 362 - Repos map[string][]db.Repo 423 + Repos map[string][]models.Repo 363 424 IsOwner bool 364 425 } 365 426 ··· 368 429 } 369 430 370 431 type KnotListingParams struct { 371 - *db.Registration 432 + *models.Registration 372 433 } 373 434 374 435 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { ··· 377 438 378 439 type SpindlesParams struct { 379 440 LoggedInUser *oauth.User 380 - Spindles []db.Spindle 441 + Spindles []models.Spindle 381 442 } 382 443 383 444 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 385 446 } 386 447 387 448 type SpindleListingParams struct { 388 - db.Spindle 449 + models.Spindle 389 450 } 390 451 391 452 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 394 455 395 456 type SpindleDashboardParams struct { 396 457 LoggedInUser *oauth.User 397 - Spindle db.Spindle 458 + Spindle models.Spindle 398 459 Members []string 399 - Repos map[string][]db.Repo 460 + Repos map[string][]models.Repo 400 461 } 401 462 402 463 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 422 483 return p.execute("repo/fork", w, params) 423 484 } 424 485 425 - type ProfileHomePageParams struct { 486 + type ProfileCard struct { 487 + UserDid string 488 + UserHandle string 489 + FollowStatus models.FollowStatus 490 + Punchcard *models.Punchcard 491 + Profile *models.Profile 492 + Stats ProfileStats 493 + Active string 494 + } 495 + 496 + type ProfileStats struct { 497 + RepoCount int64 498 + StarredCount int64 499 + StringCount int64 500 + FollowersCount int64 501 + FollowingCount int64 502 + } 503 + 504 + func (p *ProfileCard) GetTabs() [][]any { 505 + tabs := [][]any{ 506 + {"overview", "overview", "square-chart-gantt", nil}, 507 + {"repos", "repos", "book-marked", p.Stats.RepoCount}, 508 + {"starred", "starred", "star", p.Stats.StarredCount}, 509 + {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 510 + } 511 + 512 + return tabs 513 + } 514 + 515 + type ProfileOverviewParams struct { 426 516 LoggedInUser *oauth.User 427 - Repos []db.Repo 428 - CollaboratingRepos []db.Repo 429 - ProfileTimeline *db.ProfileTimeline 430 - Card ProfileCard 431 - Punchcard db.Punchcard 517 + Repos []models.Repo 518 + CollaboratingRepos []models.Repo 519 + ProfileTimeline *models.ProfileTimeline 520 + Card *ProfileCard 521 + Active string 522 + } 523 + 524 + func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 525 + params.Active = "overview" 526 + return p.executeProfile("user/overview", w, params) 527 + } 528 + 529 + type ProfileReposParams struct { 530 + LoggedInUser *oauth.User 531 + Repos []models.Repo 532 + Card *ProfileCard 533 + Active string 432 534 } 433 535 434 - type ProfileCard struct { 435 - UserDid string 436 - UserHandle string 437 - FollowStatus db.FollowStatus 438 - FollowersCount int 439 - FollowingCount int 536 + func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 537 + params.Active = "repos" 538 + return p.executeProfile("user/repos", w, params) 539 + } 440 540 441 - Profile *db.Profile 541 + type ProfileStarredParams struct { 542 + LoggedInUser *oauth.User 543 + Repos []models.Repo 544 + Card *ProfileCard 545 + Active string 442 546 } 443 547 444 - func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 445 - return p.execute("user/profile", w, params) 548 + func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 549 + params.Active = "starred" 550 + return p.executeProfile("user/starred", w, params) 446 551 } 447 552 448 - type ReposPageParams struct { 553 + type ProfileStringsParams struct { 449 554 LoggedInUser *oauth.User 450 - Repos []db.Repo 451 - Card ProfileCard 555 + Strings []models.String 556 + Card *ProfileCard 557 + Active string 452 558 } 453 559 454 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 455 - return p.execute("user/repos", w, params) 560 + func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 561 + params.Active = "strings" 562 + return p.executeProfile("user/strings", w, params) 456 563 } 457 564 458 565 type FollowCard struct { 459 566 UserDid string 460 - FollowStatus db.FollowStatus 461 - FollowersCount int 462 - FollowingCount int 463 - Profile *db.Profile 567 + LoggedInUser *oauth.User 568 + FollowStatus models.FollowStatus 569 + FollowersCount int64 570 + FollowingCount int64 571 + Profile *models.Profile 464 572 } 465 573 466 - type FollowersPageParams struct { 574 + type ProfileFollowersParams struct { 467 575 LoggedInUser *oauth.User 468 576 Followers []FollowCard 469 - Card ProfileCard 577 + Card *ProfileCard 578 + Active string 470 579 } 471 580 472 - func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 473 - return p.execute("user/followers", w, params) 581 + func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 582 + params.Active = "overview" 583 + return p.executeProfile("user/followers", w, params) 474 584 } 475 585 476 - type FollowingPageParams struct { 586 + type ProfileFollowingParams struct { 477 587 LoggedInUser *oauth.User 478 588 Following []FollowCard 479 - Card ProfileCard 589 + Card *ProfileCard 590 + Active string 480 591 } 481 592 482 - func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 483 - return p.execute("user/following", w, params) 593 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 594 + params.Active = "overview" 595 + return p.executeProfile("user/following", w, params) 484 596 } 485 597 486 598 type FollowFragmentParams struct { 487 599 UserDid string 488 - FollowStatus db.FollowStatus 600 + FollowStatus models.FollowStatus 489 601 } 490 602 491 603 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { ··· 494 606 495 607 type EditBioParams struct { 496 608 LoggedInUser *oauth.User 497 - Profile *db.Profile 609 + Profile *models.Profile 498 610 } 499 611 500 612 func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { ··· 503 615 504 616 type EditPinsParams struct { 505 617 LoggedInUser *oauth.User 506 - Profile *db.Profile 618 + Profile *models.Profile 507 619 AllRepos []PinnedRepo 508 620 } 509 621 510 622 type PinnedRepo struct { 511 623 IsPinned bool 512 - db.Repo 624 + models.Repo 513 625 } 514 626 515 627 func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { ··· 519 631 type RepoStarFragmentParams struct { 520 632 IsStarred bool 521 633 RepoAt syntax.ATURI 522 - Stats db.RepoStats 634 + Stats models.RepoStats 523 635 } 524 636 525 637 func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { ··· 552 664 EmailToDidOrHandle map[string]string 553 665 VerifiedCommits commitverify.VerifiedCommits 554 666 Languages []types.RepoLanguageDetails 555 - Pipelines map[string]db.Pipeline 667 + Pipelines map[string]models.Pipeline 668 + NeedsKnotUpgrade bool 556 669 types.RepoIndexResponse 557 670 } 558 671 ··· 562 675 return p.executeRepo("repo/empty", w, params) 563 676 } 564 677 678 + if params.NeedsKnotUpgrade { 679 + return p.executeRepo("repo/needsUpgrade", w, params) 680 + } 681 + 565 682 p.rctx.RepoInfo = params.RepoInfo 566 683 p.rctx.RepoInfo.Ref = params.Ref 567 684 p.rctx.RendererType = markup.RendererTypeRepoMarkdown ··· 590 707 Active string 591 708 EmailToDidOrHandle map[string]string 592 709 VerifiedCommits commitverify.VerifiedCommits 593 - Pipelines map[string]db.Pipeline 710 + Pipelines map[string]models.Pipeline 594 711 } 595 712 596 713 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 603 720 RepoInfo repoinfo.RepoInfo 604 721 Active string 605 722 EmailToDidOrHandle map[string]string 606 - Pipeline *db.Pipeline 723 + Pipeline *models.Pipeline 607 724 DiffOpts types.DiffOpts 608 725 609 726 // singular because it's always going to be just one ··· 623 740 Active string 624 741 BreadCrumbs [][]string 625 742 TreePath string 743 + Raw bool 744 + HTMLReadme template.HTML 626 745 types.RepoTreeResponse 627 746 } 628 747 ··· 649 768 650 769 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 651 770 params.Active = "overview" 652 - return p.execute("repo/tree", w, params) 771 + 772 + p.rctx.RepoInfo = params.RepoInfo 773 + p.rctx.RepoInfo.Ref = params.Ref 774 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 775 + 776 + if params.ReadmeFileName != "" { 777 + ext := filepath.Ext(params.ReadmeFileName) 778 + switch ext { 779 + case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 780 + params.Raw = false 781 + htmlString := p.rctx.RenderMarkdown(params.Readme) 782 + sanitized := p.rctx.SanitizeDefault(htmlString) 783 + params.HTMLReadme = template.HTML(sanitized) 784 + default: 785 + params.Raw = true 786 + } 787 + } 788 + 789 + return p.executeRepo("repo/tree", w, params) 653 790 } 654 791 655 792 type RepoBranchesParams struct { ··· 669 806 RepoInfo repoinfo.RepoInfo 670 807 Active string 671 808 types.RepoTagsResponse 672 - ArtifactMap map[plumbing.Hash][]db.Artifact 673 - DanglingArtifacts []db.Artifact 809 + ArtifactMap map[plumbing.Hash][]models.Artifact 810 + DanglingArtifacts []models.Artifact 674 811 } 675 812 676 813 func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { ··· 681 818 type RepoArtifactParams struct { 682 819 LoggedInUser *oauth.User 683 820 RepoInfo repoinfo.RepoInfo 684 - Artifact db.Artifact 821 + Artifact models.Artifact 685 822 } 686 823 687 824 func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { ··· 700 837 ShowRendered bool 701 838 RenderToggle bool 702 839 RenderedContents template.HTML 703 - types.RepoBlobResponse 840 + *tangled.RepoBlob_Output 841 + // Computed fields for template compatibility 842 + Contents string 843 + Lines int 844 + SizeHint uint64 845 + IsBinary bool 704 846 } 705 847 706 848 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 773 915 } 774 916 775 917 type RepoGeneralSettingsParams struct { 776 - LoggedInUser *oauth.User 777 - RepoInfo repoinfo.RepoInfo 778 - Active string 779 - Tabs []map[string]any 780 - Tab string 781 - Branches []types.Branch 918 + LoggedInUser *oauth.User 919 + RepoInfo repoinfo.RepoInfo 920 + Labels []models.LabelDefinition 921 + DefaultLabels []models.LabelDefinition 922 + SubscribedLabels map[string]struct{} 923 + ShouldSubscribeAll bool 924 + Active string 925 + Tabs []map[string]any 926 + Tab string 927 + Branches []types.Branch 782 928 } 783 929 784 930 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { ··· 820 966 LoggedInUser *oauth.User 821 967 RepoInfo repoinfo.RepoInfo 822 968 Active string 823 - Issues []db.Issue 969 + Issues []models.Issue 970 + LabelDefs map[string]*models.LabelDefinition 824 971 Page pagination.Page 825 972 FilteringByOpen bool 826 973 } ··· 831 978 } 832 979 833 980 type RepoSingleIssueParams struct { 834 - LoggedInUser *oauth.User 835 - RepoInfo repoinfo.RepoInfo 836 - Active string 837 - Issue *db.Issue 838 - Comments []db.Comment 839 - IssueOwnerHandle string 981 + LoggedInUser *oauth.User 982 + RepoInfo repoinfo.RepoInfo 983 + Active string 984 + Issue *models.Issue 985 + CommentList []models.CommentListItem 986 + LabelDefs map[string]*models.LabelDefinition 987 + 988 + OrderedReactionKinds []models.ReactionKind 989 + Reactions map[models.ReactionKind]models.ReactionDisplayData 990 + UserReacted map[models.ReactionKind]bool 991 + } 992 + 993 + func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 994 + params.Active = "issues" 995 + return p.executeRepo("repo/issues/issue", w, params) 996 + } 840 997 841 - OrderedReactionKinds []db.ReactionKind 842 - Reactions map[db.ReactionKind]int 843 - UserReacted map[db.ReactionKind]bool 998 + type EditIssueParams struct { 999 + LoggedInUser *oauth.User 1000 + RepoInfo repoinfo.RepoInfo 1001 + Issue *models.Issue 1002 + Action string 1003 + } 844 1004 845 - State string 1005 + func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 1006 + params.Action = "edit" 1007 + return p.executePlain("repo/issues/fragments/putIssue", w, params) 846 1008 } 847 1009 848 1010 type ThreadReactionFragmentParams struct { 849 1011 ThreadAt syntax.ATURI 850 - Kind db.ReactionKind 1012 + Kind models.ReactionKind 851 1013 Count int 1014 + Users []string 852 1015 IsReacted bool 853 1016 } 854 1017 ··· 856 1019 return p.executePlain("repo/fragments/reaction", w, params) 857 1020 } 858 1021 859 - func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 860 - params.Active = "issues" 861 - if params.Issue.Open { 862 - params.State = "open" 863 - } else { 864 - params.State = "closed" 865 - } 866 - return p.execute("repo/issues/issue", w, params) 867 - } 868 - 869 1022 type RepoNewIssueParams struct { 870 1023 LoggedInUser *oauth.User 871 1024 RepoInfo repoinfo.RepoInfo 1025 + Issue *models.Issue // existing issue if any -- passed when editing 872 1026 Active string 1027 + Action string 873 1028 } 874 1029 875 1030 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 876 1031 params.Active = "issues" 1032 + params.Action = "create" 877 1033 return p.executeRepo("repo/issues/new", w, params) 878 1034 } 879 1035 880 1036 type EditIssueCommentParams struct { 881 1037 LoggedInUser *oauth.User 882 1038 RepoInfo repoinfo.RepoInfo 883 - Issue *db.Issue 884 - Comment *db.Comment 1039 + Issue *models.Issue 1040 + Comment *models.IssueComment 885 1041 } 886 1042 887 1043 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 888 1044 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 889 1045 } 890 1046 891 - type SingleIssueCommentParams struct { 1047 + type ReplyIssueCommentPlaceholderParams struct { 1048 + LoggedInUser *oauth.User 1049 + RepoInfo repoinfo.RepoInfo 1050 + Issue *models.Issue 1051 + Comment *models.IssueComment 1052 + } 1053 + 1054 + func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 1055 + return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 1056 + } 1057 + 1058 + type ReplyIssueCommentParams struct { 1059 + LoggedInUser *oauth.User 1060 + RepoInfo repoinfo.RepoInfo 1061 + Issue *models.Issue 1062 + Comment *models.IssueComment 1063 + } 1064 + 1065 + func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 1066 + return p.executePlain("repo/issues/fragments/replyComment", w, params) 1067 + } 1068 + 1069 + type IssueCommentBodyParams struct { 892 1070 LoggedInUser *oauth.User 893 1071 RepoInfo repoinfo.RepoInfo 894 - Issue *db.Issue 895 - Comment *db.Comment 1072 + Issue *models.Issue 1073 + Comment *models.IssueComment 896 1074 } 897 1075 898 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 899 - return p.executePlain("repo/issues/fragments/issueComment", w, params) 1076 + func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 1077 + return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 900 1078 } 901 1079 902 1080 type RepoNewPullParams struct { ··· 919 1097 type RepoPullsParams struct { 920 1098 LoggedInUser *oauth.User 921 1099 RepoInfo repoinfo.RepoInfo 922 - Pulls []*db.Pull 1100 + Pulls []*models.Pull 923 1101 Active string 924 - FilteringBy db.PullState 925 - Stacks map[string]db.Stack 926 - Pipelines map[string]db.Pipeline 1102 + FilteringBy models.PullState 1103 + Stacks map[string]models.Stack 1104 + Pipelines map[string]models.Pipeline 1105 + LabelDefs map[string]*models.LabelDefinition 927 1106 } 928 1107 929 1108 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 950 1129 } 951 1130 952 1131 type RepoSinglePullParams struct { 953 - LoggedInUser *oauth.User 954 - RepoInfo repoinfo.RepoInfo 955 - Active string 956 - Pull *db.Pull 957 - Stack db.Stack 958 - AbandonedPulls []*db.Pull 959 - MergeCheck types.MergeCheckResponse 960 - ResubmitCheck ResubmitResult 961 - Pipelines map[string]db.Pipeline 1132 + LoggedInUser *oauth.User 1133 + RepoInfo repoinfo.RepoInfo 1134 + Active string 1135 + Pull *models.Pull 1136 + Stack models.Stack 1137 + AbandonedPulls []*models.Pull 1138 + BranchDeleteStatus *models.BranchDeleteStatus 1139 + MergeCheck types.MergeCheckResponse 1140 + ResubmitCheck ResubmitResult 1141 + Pipelines map[string]models.Pipeline 1142 + 1143 + OrderedReactionKinds []models.ReactionKind 1144 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1145 + UserReacted map[models.ReactionKind]bool 962 1146 963 - OrderedReactionKinds []db.ReactionKind 964 - Reactions map[db.ReactionKind]int 965 - UserReacted map[db.ReactionKind]bool 1147 + LabelDefs map[string]*models.LabelDefinition 966 1148 } 967 1149 968 1150 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 973 1155 type RepoPullPatchParams struct { 974 1156 LoggedInUser *oauth.User 975 1157 RepoInfo repoinfo.RepoInfo 976 - Pull *db.Pull 977 - Stack db.Stack 1158 + Pull *models.Pull 1159 + Stack models.Stack 978 1160 Diff *types.NiceDiff 979 1161 Round int 980 - Submission *db.PullSubmission 981 - OrderedReactionKinds []db.ReactionKind 1162 + Submission *models.PullSubmission 1163 + OrderedReactionKinds []models.ReactionKind 982 1164 DiffOpts types.DiffOpts 983 1165 } 984 1166 ··· 990 1172 type RepoPullInterdiffParams struct { 991 1173 LoggedInUser *oauth.User 992 1174 RepoInfo repoinfo.RepoInfo 993 - Pull *db.Pull 1175 + Pull *models.Pull 994 1176 Round int 995 1177 Interdiff *patchutil.InterdiffResult 996 - OrderedReactionKinds []db.ReactionKind 1178 + OrderedReactionKinds []models.ReactionKind 997 1179 DiffOpts types.DiffOpts 998 1180 } 999 1181 ··· 1022 1204 1023 1205 type PullCompareForkParams struct { 1024 1206 RepoInfo repoinfo.RepoInfo 1025 - Forks []db.Repo 1207 + Forks []models.Repo 1026 1208 Selected string 1027 1209 } 1028 1210 ··· 1043 1225 type PullResubmitParams struct { 1044 1226 LoggedInUser *oauth.User 1045 1227 RepoInfo repoinfo.RepoInfo 1046 - Pull *db.Pull 1228 + Pull *models.Pull 1047 1229 SubmissionId int 1048 1230 } 1049 1231 ··· 1052 1234 } 1053 1235 1054 1236 type PullActionsParams struct { 1055 - LoggedInUser *oauth.User 1056 - RepoInfo repoinfo.RepoInfo 1057 - Pull *db.Pull 1058 - RoundNumber int 1059 - MergeCheck types.MergeCheckResponse 1060 - ResubmitCheck ResubmitResult 1061 - Stack db.Stack 1237 + LoggedInUser *oauth.User 1238 + RepoInfo repoinfo.RepoInfo 1239 + Pull *models.Pull 1240 + RoundNumber int 1241 + MergeCheck types.MergeCheckResponse 1242 + ResubmitCheck ResubmitResult 1243 + BranchDeleteStatus *models.BranchDeleteStatus 1244 + Stack models.Stack 1062 1245 } 1063 1246 1064 1247 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1068 1251 type PullNewCommentParams struct { 1069 1252 LoggedInUser *oauth.User 1070 1253 RepoInfo repoinfo.RepoInfo 1071 - Pull *db.Pull 1254 + Pull *models.Pull 1072 1255 RoundNumber int 1073 1256 } 1074 1257 ··· 1079 1262 type RepoCompareParams struct { 1080 1263 LoggedInUser *oauth.User 1081 1264 RepoInfo repoinfo.RepoInfo 1082 - Forks []db.Repo 1265 + Forks []models.Repo 1083 1266 Branches []types.Branch 1084 1267 Tags []*types.TagReference 1085 1268 Base string ··· 1098 1281 type RepoCompareNewParams struct { 1099 1282 LoggedInUser *oauth.User 1100 1283 RepoInfo repoinfo.RepoInfo 1101 - Forks []db.Repo 1284 + Forks []models.Repo 1102 1285 Branches []types.Branch 1103 1286 Tags []*types.TagReference 1104 1287 Base string ··· 1133 1316 return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1134 1317 } 1135 1318 1319 + type LabelPanelParams struct { 1320 + LoggedInUser *oauth.User 1321 + RepoInfo repoinfo.RepoInfo 1322 + Defs map[string]*models.LabelDefinition 1323 + Subject string 1324 + State models.LabelState 1325 + } 1326 + 1327 + func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { 1328 + return p.executePlain("repo/fragments/labelPanel", w, params) 1329 + } 1330 + 1331 + type EditLabelPanelParams struct { 1332 + LoggedInUser *oauth.User 1333 + RepoInfo repoinfo.RepoInfo 1334 + Defs map[string]*models.LabelDefinition 1335 + Subject string 1336 + State models.LabelState 1337 + } 1338 + 1339 + func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { 1340 + return p.executePlain("repo/fragments/editLabelPanel", w, params) 1341 + } 1342 + 1136 1343 type PipelinesParams struct { 1137 1344 LoggedInUser *oauth.User 1138 1345 RepoInfo repoinfo.RepoInfo 1139 - Pipelines []db.Pipeline 1346 + Pipelines []models.Pipeline 1140 1347 Active string 1141 1348 } 1142 1349 ··· 1168 1375 type WorkflowParams struct { 1169 1376 LoggedInUser *oauth.User 1170 1377 RepoInfo repoinfo.RepoInfo 1171 - Pipeline db.Pipeline 1378 + Pipeline models.Pipeline 1172 1379 Workflow string 1173 1380 LogUrl string 1174 1381 Active string ··· 1184 1391 Action string 1185 1392 1186 1393 // this is supplied in the case of editing an existing string 1187 - String db.String 1394 + String models.String 1188 1395 } 1189 1396 1190 1397 func (p *Pages) PutString(w io.Writer, params PutStringParams) error { ··· 1194 1401 type StringsDashboardParams struct { 1195 1402 LoggedInUser *oauth.User 1196 1403 Card ProfileCard 1197 - Strings []db.String 1404 + Strings []models.String 1198 1405 } 1199 1406 1200 1407 func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { ··· 1203 1410 1204 1411 type StringTimelineParams struct { 1205 1412 LoggedInUser *oauth.User 1206 - Strings []db.String 1413 + Strings []models.String 1207 1414 } 1208 1415 1209 1416 func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { ··· 1215 1422 ShowRendered bool 1216 1423 RenderToggle bool 1217 1424 RenderedContents template.HTML 1218 - String db.String 1219 - Stats db.StringStats 1425 + String models.String 1426 + Stats models.StringStats 1220 1427 Owner identity.Identity 1221 1428 } 1222 1429 ··· 1262 1469 return p.execute("strings/string", w, params) 1263 1470 } 1264 1471 1472 + func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1473 + return p.execute("timeline/home", w, params) 1474 + } 1475 + 1265 1476 func (p *Pages) Static() http.Handler { 1266 1477 if p.dev { 1267 1478 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1268 1479 } 1269 1480 1270 - sub, err := fs.Sub(Files, "static") 1481 + sub, err := fs.Sub(p.embedFS, "static") 1271 1482 if err != nil { 1272 - log.Fatalf("no static dir found? that's crazy: %v", err) 1483 + p.logger.Error("no static dir found? that's crazy", "err", err) 1484 + panic(err) 1273 1485 } 1274 1486 // Custom handler to apply Cache-Control headers for font files 1275 1487 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1289 1501 }) 1290 1502 } 1291 1503 1292 - func CssContentHash() string { 1293 - cssFile, err := Files.Open("static/tw.css") 1504 + func (p *Pages) CssContentHash() string { 1505 + cssFile, err := p.embedFS.Open("static/tw.css") 1294 1506 if err != nil { 1295 - log.Printf("Error opening CSS file: %v", err) 1507 + slog.Debug("Error opening CSS file", "err", err) 1296 1508 return "" 1297 1509 } 1298 1510 defer cssFile.Close() 1299 1511 1300 1512 hasher := sha256.New() 1301 1513 if _, err := io.Copy(hasher, cssFile); err != nil { 1302 - log.Printf("Error hashing CSS file: %v", err) 1514 + slog.Debug("Error hashing CSS file", "err", err) 1303 1515 return "" 1304 1516 } 1305 1517
+9 -13
appview/pages/repoinfo/repoinfo.go
··· 7 7 "strings" 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.sh/tangled.sh/core/appview/db" 11 - "tangled.sh/tangled.sh/core/appview/state/userutil" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/state/userutil" 12 12 ) 13 13 14 14 func (r RepoInfo) OwnerWithAt() string { ··· 24 24 } 25 25 26 26 func (r RepoInfo) OwnerWithoutAt() string { 27 - if strings.HasPrefix(r.OwnerWithAt(), "@") { 28 - return strings.TrimPrefix(r.OwnerWithAt(), "@") 27 + if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok { 28 + return after 29 29 } else { 30 30 return userutil.FlattenDid(r.OwnerDid) 31 31 } ··· 52 52 53 53 type RepoInfo struct { 54 54 Name string 55 + Rkey string 55 56 OwnerDid string 56 57 OwnerHandle string 57 58 Description string ··· 59 60 Spindle string 60 61 RepoAt syntax.ATURI 61 62 IsStarred bool 62 - Stats db.RepoStats 63 + Stats models.RepoStats 63 64 Roles RolesInRepo 64 - Source *db.Repo 65 + Source *models.Repo 65 66 SourceHandle string 66 67 Ref string 67 68 DisableFork bool ··· 78 79 func (r RepoInfo) TabMetadata() map[string]any { 79 80 meta := make(map[string]any) 80 81 81 - if r.Stats.PullCount.Open > 0 { 82 - meta["pulls"] = r.Stats.PullCount.Open 83 - } 84 - 85 - if r.Stats.IssueCount.Open > 0 { 86 - meta["issues"] = r.Stats.IssueCount.Open 87 - } 82 + meta["pulls"] = r.Stats.PullCount.Open 83 + meta["issues"] = r.Stats.IssueCount.Open 88 84 89 85 // more stuff? 90 86
+38
appview/pages/templates/banner.html
··· 1 + {{ define "banner" }} 2 + <div class="flex items-center justify-center mx-auto w-full bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-b drop-shadow-sm text-red-800 dark:text-red-200"> 3 + <details class="group p-2"> 4 + <summary class="list-none cursor-pointer"> 5 + <div class="flex gap-4 items-center"> 6 + <span class="group-open:hidden inline">{{ i "triangle-alert" "w-4 h-4" }}</span> 7 + <span class="hidden group-open:inline">{{ i "x" "w-4 h-4" }}</span> 8 + 9 + <span class="group-open:hidden inline">Some services that you administer require an update. Click to show more.</span> 10 + <span class="hidden group-open:inline">Some services that you administer will have to be updated to be compatible with Tangled.</span> 11 + </div> 12 + </summary> 13 + 14 + {{ if .Registrations }} 15 + <ul class="list-disc mx-12 my-2"> 16 + {{range .Registrations}} 17 + <li>Knot: {{ .Domain }}</li> 18 + {{ end }} 19 + </ul> 20 + {{ end }} 21 + 22 + {{ if .Spindles }} 23 + <ul class="list-disc mx-12 my-2"> 24 + {{range .Spindles}} 25 + <li>Spindle: {{ .Instance }}</li> 26 + {{ end }} 27 + </ul> 28 + {{ end }} 29 + 30 + <div class="mx-6"> 31 + These services may not be fully accessible until upgraded. 32 + <a class="underline text-red-800 dark:text-red-200" 33 + href="https://tangled.org/@tangled.org/core/tree/master/docs/migrations.md"> 34 + Click to read the upgrade guide</a>. 35 + </div> 36 + </details> 37 + </div> 38 + {{ end }}
+224
appview/pages/templates/brand/brand.html
··· 1 + {{ define "title" }}brand{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Assets and guidelines for using Tangled's logo and brand elements. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="space-y-16"> 14 + 15 + <!-- Introduction Section --> 16 + <section> 17 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 18 + Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 + follow the below guidelines when using Dolly and the logotype. 20 + </p> 21 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 22 + All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 + </p> 24 + </section> 25 + 26 + <!-- Black Logotype Section --> 27 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 28 + <div class="order-2 lg:order-1"> 29 + <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 30 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 31 + alt="Tangled logo - black version" 32 + class="w-full max-w-sm mx-auto" /> 33 + </div> 34 + </div> 35 + <div class="order-1 lg:order-2"> 36 + <h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2> 37 + <p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p> 38 + <p class="text-gray-700 dark:text-gray-300"> 39 + This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 + backgrounds and designs. 41 + </p> 42 + </div> 43 + </section> 44 + 45 + <!-- White Logotype Section --> 46 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 47 + <div class="order-2 lg:order-1"> 48 + <div class="bg-black p-8 sm:p-16 rounded"> 49 + <img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg" 50 + alt="Tangled logo - white version" 51 + class="w-full max-w-sm mx-auto" /> 52 + </div> 53 + </div> 54 + <div class="order-1 lg:order-2"> 55 + <h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2> 56 + <p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p> 57 + <p class="text-gray-700 dark:text-gray-300"> 58 + This version features white text and elements, ideal for dark backgrounds 59 + and inverted designs. 60 + </p> 61 + </div> 62 + </section> 63 + 64 + <!-- Mark Only Section --> 65 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 66 + <div class="order-2 lg:order-1"> 67 + <div class="grid grid-cols-2 gap-2"> 68 + <!-- Black mark on light background --> 69 + <div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded"> 70 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 71 + alt="Dolly face - black version" 72 + class="w-full max-w-16 mx-auto" /> 73 + </div> 74 + <!-- White mark on dark background --> 75 + <div class="bg-black p-8 sm:p-12 rounded"> 76 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 77 + alt="Dolly face - white version" 78 + class="w-full max-w-16 mx-auto" /> 79 + </div> 80 + </div> 81 + </div> 82 + <div class="order-1 lg:order-2"> 83 + <h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2> 84 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 85 + When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 + </p> 87 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 88 + <strong class="font-semibold">Note</strong>: for situations where the background 89 + is unknown, use the black version for ideal contrast in most environments. 90 + </p> 91 + </div> 92 + </section> 93 + 94 + <!-- Colored Backgrounds Section --> 95 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 96 + <div class="order-2 lg:order-1"> 97 + <div class="grid grid-cols-2 gap-2"> 98 + <!-- Pastel Green background --> 99 + <div class="bg-green-500 p-8 sm:p-12 rounded"> 100 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 101 + alt="Tangled logo on pastel green background" 102 + class="w-full max-w-16 mx-auto" /> 103 + </div> 104 + <!-- Pastel Blue background --> 105 + <div class="bg-blue-500 p-8 sm:p-12 rounded"> 106 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 107 + alt="Tangled logo on pastel blue background" 108 + class="w-full max-w-16 mx-auto" /> 109 + </div> 110 + <!-- Pastel Yellow background --> 111 + <div class="bg-yellow-500 p-8 sm:p-12 rounded"> 112 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 113 + alt="Tangled logo on pastel yellow background" 114 + class="w-full max-w-16 mx-auto" /> 115 + </div> 116 + <!-- Pastel Red background --> 117 + <div class="bg-red-500 p-8 sm:p-12 rounded"> 118 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 119 + alt="Tangled logo on pastel red background" 120 + class="w-full max-w-16 mx-auto" /> 121 + </div> 122 + </div> 123 + </div> 124 + <div class="order-1 lg:order-2"> 125 + <h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2> 126 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 127 + White logo mark on colored backgrounds. 128 + </p> 129 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 130 + The white logo mark provides contrast on colored backgrounds. 131 + Perfect for more fun design contexts. 132 + </p> 133 + </div> 134 + </section> 135 + 136 + <!-- Black Logo on Pastel Backgrounds Section --> 137 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 138 + <div class="order-2 lg:order-1"> 139 + <div class="grid grid-cols-2 gap-2"> 140 + <!-- Pastel Green background --> 141 + <div class="bg-green-200 p-8 sm:p-12 rounded"> 142 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 143 + alt="Tangled logo on pastel green background" 144 + class="w-full max-w-16 mx-auto" /> 145 + </div> 146 + <!-- Pastel Blue background --> 147 + <div class="bg-blue-200 p-8 sm:p-12 rounded"> 148 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 149 + alt="Tangled logo on pastel blue background" 150 + class="w-full max-w-16 mx-auto" /> 151 + </div> 152 + <!-- Pastel Yellow background --> 153 + <div class="bg-yellow-200 p-8 sm:p-12 rounded"> 154 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 155 + alt="Tangled logo on pastel yellow background" 156 + class="w-full max-w-16 mx-auto" /> 157 + </div> 158 + <!-- Pastel Pink background --> 159 + <div class="bg-pink-200 p-8 sm:p-12 rounded"> 160 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 161 + alt="Tangled logo on pastel pink background" 162 + class="w-full max-w-16 mx-auto" /> 163 + </div> 164 + </div> 165 + </div> 166 + <div class="order-1 lg:order-2"> 167 + <h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2> 168 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 169 + Dark logo mark on lighter, pastel backgrounds. 170 + </p> 171 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 172 + The dark logo mark works beautifully on pastel backgrounds, 173 + providing crisp contrast. 174 + </p> 175 + </div> 176 + </section> 177 + 178 + <!-- Recoloring Section --> 179 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 180 + <div class="order-2 lg:order-1"> 181 + <div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded"> 182 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 183 + alt="Recolored Tangled logotype in gray/sand color" 184 + class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" /> 185 + </div> 186 + </div> 187 + <div class="order-1 lg:order-2"> 188 + <h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2> 189 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 190 + Custom coloring of the logotype is permitted. 191 + </p> 192 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 193 + Recoloring the logotype is allowed as long as readability is maintained. 194 + </p> 195 + <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 + <strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background. 197 + </p> 198 + </div> 199 + </section> 200 + 201 + <!-- Silhouette Section --> 202 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 203 + <div class="order-2 lg:order-1"> 204 + <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 205 + <img src="https://assets.tangled.network/tangled_dolly_silhouette.svg" 206 + alt="Dolly silhouette" 207 + class="w-full max-w-32 mx-auto" /> 208 + </div> 209 + </div> 210 + <div class="order-1 lg:order-2"> 211 + <h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2> 212 + <p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p> 213 + <p class="text-gray-700 dark:text-gray-300"> 214 + The silhouette can be used where a subtle brand presence is needed, 215 + or as a background element. Works on any background color with proper contrast. 216 + For example, we use this as the site's favicon. 217 + </p> 218 + </div> 219 + </section> 220 + 221 + </div> 222 + </main> 223 + </div> 224 + {{ end }}
+1 -1
appview/pages/templates/errors/404.html
··· 17 17 The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 18 </p> 19 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 20 + <a href="javascript:history.back()" class="btn no-underline hover:no-underline gap-2"> 21 21 {{ i "arrow-left" "w-4 h-4" }} 22 22 go back 23 23 </a>
+8 -15
appview/pages/templates/errors/500.html
··· 5 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 6 <div class="mb-6"> 7 7 <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 - {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 8 + {{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 9 </div> 10 10 </div> 11 - 11 + 12 12 <div class="space-y-4"> 13 13 <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 14 500 &mdash; internal server error 15 15 </h1> 16 16 <p class="text-gray-600 dark:text-gray-300"> 17 - Something went wrong on our end. We've been notified and are working to fix the issue. 18 - </p> 19 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 - <div class="flex items-center gap-2"> 21 - {{ i "info" "w-4 h-4" }} 22 - <span class="font-medium">we're on it!</span> 23 - </div> 24 - <p class="mt-1">Our team has been automatically notified about this error.</p> 25 - </div> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 26 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 20 + <button onclick="location.reload()" class="btn-create gap-2"> 28 21 {{ i "refresh-cw" "w-4 h-4" }} 29 22 try again 30 23 </button> 31 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 32 - {{ i "home" "w-4 h-4" }} 24 + <a href="/" class="btn no-underline hover:no-underline gap-2"> 25 + {{ i "arrow-left" "w-4 h-4" }} 33 26 back to home 34 27 </a> 35 28 </div> 36 29 </div> 37 30 </div> 38 31 </div> 39 - {{ end }} 32 + {{ end }}
+2 -2
appview/pages/templates/errors/503.html
··· 17 17 We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 18 </p> 19 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 20 + <button onclick="location.reload()" class="btn-create gap-2"> 21 21 {{ i "refresh-cw" "w-4 h-4" }} 22 22 try again 23 23 </button> 24 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 24 + <a href="/" class="btn gap-2 no-underline hover:no-underline"> 25 25 {{ i "arrow-left" "w-4 h-4" }} 26 26 back to timeline 27 27 </a>
+1 -1
appview/pages/templates/errors/knot404.html
··· 17 17 The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 18 </p> 19 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline"> 20 + <a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline"> 21 21 {{ i "arrow-left" "w-4 h-4" }} 22 22 back to timeline 23 23 </a>
-26
appview/pages/templates/favicon.html
··· 1 - {{ define "favicon" }} 2 - <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"> 3 - <style> 4 - .favicon-text { 5 - fill: #000000; 6 - stroke: none; 7 - } 8 - 9 - @media (prefers-color-scheme: dark) { 10 - .favicon-text { 11 - fill: #ffffff; 12 - stroke: none; 13 - } 14 - } 15 - </style> 16 - 17 - <g style="display:inline"> 18 - <path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/> 19 - <path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z" 20 - aria-label="tangled.sh" 21 - class="favicon-text" 22 - style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1" 23 - transform="translate(11.01 6.9)"/> 24 - </g> 25 - </svg> 26 - {{ end }}
+56
appview/pages/templates/fragments/dolly/logo.html
··· 1 + {{ define "fragments/dolly/logo" }} 2 + <svg 3 + version="1.1" 4 + id="svg1" 5 + class="{{.}}" 6 + width="25" 7 + height="25" 8 + viewBox="0 0 25 25" 9 + sodipodi:docname="tangled_dolly_face_only.png" 10 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 11 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 12 + xmlns:xlink="http://www.w3.org/1999/xlink" 13 + xmlns="http://www.w3.org/2000/svg" 14 + xmlns:svg="http://www.w3.org/2000/svg"> 15 + <title>Dolly</title> 16 + <defs 17 + id="defs1" /> 18 + <sodipodi:namedview 19 + id="namedview1" 20 + pagecolor="#ffffff" 21 + bordercolor="#000000" 22 + borderopacity="0.25" 23 + inkscape:showpageshadow="2" 24 + inkscape:pageopacity="0.0" 25 + inkscape:pagecheckerboard="true" 26 + inkscape:deskcolor="#d5d5d5"> 27 + <inkscape:page 28 + x="0" 29 + y="0" 30 + width="25" 31 + height="25" 32 + id="page2" 33 + margin="0" 34 + bleed="0" /> 35 + </sodipodi:namedview> 36 + <g 37 + inkscape:groupmode="layer" 38 + inkscape:label="Image" 39 + id="g1"> 40 + <image 41 + width="252.48" 42 + height="248.96001" 43 + preserveAspectRatio="none" 44 + xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9&#10;kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI&#10;foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7&#10;vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0&#10;M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp&#10;rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T&#10;IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0&#10;AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI&#10;WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk&#10;IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39&#10;NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz&#10;3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS&#10;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&#10;U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp&#10;YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu&#10;0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd&#10;bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1&#10;MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7&#10;hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG&#10;0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A&#10;rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/&#10;//6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z&#10;k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf&#10;f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF&#10;HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK&#10;KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj&#10;4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC&#10;kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC&#10;/wcO9A7eMaXQEQAAAABJRU5ErkJggg==&#10;" 45 + id="image1" 46 + x="-233.6257" 47 + y="10.383364" 48 + style="display:none" /> 49 + <path 50 + fill="currentColor" 51 + style="stroke-width:0.111183" 52 + d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" 53 + id="path4" /> 54 + </g> 55 + </svg> 56 + {{ end }}
+57
appview/pages/templates/fragments/dolly/silhouette.html
··· 1 + {{ define "fragments/dolly/silhouette" }} 2 + <svg 3 + version="1.1" 4 + id="svg1" 5 + width="32" 6 + height="32" 7 + viewBox="0 0 25 25" 8 + sodipodi:docname="tangled_dolly_silhouette.png" 9 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 10 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 11 + xmlns="http://www.w3.org/2000/svg" 12 + xmlns:svg="http://www.w3.org/2000/svg"> 13 + <style> 14 + .dolly { 15 + color: #000000; 16 + } 17 + 18 + @media (prefers-color-scheme: dark) { 19 + .dolly { 20 + color: #ffffff; 21 + } 22 + } 23 + </style> 24 + <title>Dolly</title> 25 + <defs 26 + id="defs1" /> 27 + <sodipodi:namedview 28 + id="namedview1" 29 + pagecolor="#ffffff" 30 + bordercolor="#000000" 31 + borderopacity="0.25" 32 + inkscape:showpageshadow="2" 33 + inkscape:pageopacity="0.0" 34 + inkscape:pagecheckerboard="true" 35 + inkscape:deskcolor="#d1d1d1"> 36 + <inkscape:page 37 + x="0" 38 + y="0" 39 + width="25" 40 + height="25" 41 + id="page2" 42 + margin="0" 43 + bleed="0" /> 44 + </sodipodi:namedview> 45 + <g 46 + inkscape:groupmode="layer" 47 + inkscape:label="Image" 48 + id="g1"> 49 + <path 50 + class="dolly" 51 + fill="currentColor" 52 + style="stroke-width:1.12248" 53 + d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 54 + id="path1" /> 55 + </g> 56 + </svg> 57 + {{ end }}
+44
appview/pages/templates/fragments/dolly/silhouette.svg
··· 1 + <svg 2 + version="1.1" 3 + id="svg1" 4 + width="32" 5 + height="32" 6 + viewBox="0 0 25 25" 7 + sodipodi:docname="tangled_dolly_silhouette.png" 8 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 9 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 10 + xmlns="http://www.w3.org/2000/svg" 11 + xmlns:svg="http://www.w3.org/2000/svg"> 12 + <title>Dolly</title> 13 + <defs 14 + id="defs1" /> 15 + <sodipodi:namedview 16 + id="namedview1" 17 + pagecolor="#ffffff" 18 + bordercolor="#000000" 19 + borderopacity="0.25" 20 + inkscape:showpageshadow="2" 21 + inkscape:pageopacity="0.0" 22 + inkscape:pagecheckerboard="true" 23 + inkscape:deskcolor="#d1d1d1"> 24 + <inkscape:page 25 + x="0" 26 + y="0" 27 + width="25" 28 + height="25" 29 + id="page2" 30 + margin="0" 31 + bleed="0" /> 32 + </sodipodi:namedview> 33 + <g 34 + inkscape:groupmode="layer" 35 + inkscape:label="Image" 36 + id="g1"> 37 + <path 38 + class="dolly" 39 + fill="currentColor" 40 + style="stroke-width:1.12248" 41 + d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 42 + id="path1" /> 43 + </g> 44 + </svg>
+9
appview/pages/templates/fragments/logotype.html
··· 1 + {{ define "fragments/logotype" }} 2 + <span class="flex items-center gap-2"> 3 + {{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }} 4 + <span class="font-bold text-4xl not-italic">tangled</span> 5 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 + alpha 7 + </span> 8 + <span> 9 + {{ end }}
+9
appview/pages/templates/fragments/logotypeSmall.html
··· 1 + {{ define "fragments/logotypeSmall" }} 2 + <span class="flex items-center gap-2"> 3 + {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 4 + <span class="font-bold text-xl not-italic">tangled</span> 5 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 + alpha 7 + </span> 8 + <span> 9 + {{ end }}
+90
appview/pages/templates/fragments/multiline-select.html
··· 1 + {{ define "fragments/multiline-select" }} 2 + <script> 3 + function highlight(scroll = false) { 4 + document.querySelectorAll(".hl").forEach(el => { 5 + el.classList.remove("hl"); 6 + }); 7 + 8 + const hash = window.location.hash; 9 + if (!hash || !hash.startsWith("#L")) { 10 + return; 11 + } 12 + 13 + const rangeStr = hash.substring(2); 14 + const parts = rangeStr.split("-"); 15 + let startLine, endLine; 16 + 17 + if (parts.length === 2) { 18 + startLine = parseInt(parts[0], 10); 19 + endLine = parseInt(parts[1], 10); 20 + } else { 21 + startLine = parseInt(parts[0], 10); 22 + endLine = startLine; 23 + } 24 + 25 + if (isNaN(startLine) || isNaN(endLine)) { 26 + console.log("nan"); 27 + console.log(startLine); 28 + console.log(endLine); 29 + return; 30 + } 31 + 32 + let target = null; 33 + 34 + for (let i = startLine; i<= endLine; i++) { 35 + const idEl = document.getElementById(`L${i}`); 36 + if (idEl) { 37 + const el = idEl.closest(".line"); 38 + if (el) { 39 + el.classList.add("hl"); 40 + target = el; 41 + } 42 + } 43 + } 44 + 45 + if (scroll && target) { 46 + target.scrollIntoView({ 47 + behavior: "smooth", 48 + block: "center", 49 + }); 50 + } 51 + } 52 + 53 + document.addEventListener("DOMContentLoaded", () => { 54 + console.log("DOMContentLoaded"); 55 + highlight(true); 56 + }); 57 + window.addEventListener("hashchange", () => { 58 + console.log("hashchange"); 59 + highlight(); 60 + }); 61 + window.addEventListener("popstate", () => { 62 + console.log("popstate"); 63 + highlight(); 64 + }); 65 + 66 + const lineNumbers = document.querySelectorAll('a[href^="#L"'); 67 + let startLine = null; 68 + 69 + lineNumbers.forEach(el => { 70 + el.addEventListener("click", (event) => { 71 + event.preventDefault(); 72 + const currentLine = parseInt(el.href.split("#L")[1]); 73 + 74 + if (event.shiftKey && startLine !== null) { 75 + const endLine = currentLine; 76 + const min = Math.min(startLine, endLine); 77 + const max = Math.max(startLine, endLine); 78 + const newHash = `#L${min}-${max}`; 79 + history.pushState(null, '', newHash); 80 + } else { 81 + const newHash = `#L${currentLine}`; 82 + history.pushState(null, '', newHash); 83 + startLine = currentLine; 84 + } 85 + 86 + highlight(); 87 + }); 88 + }); 89 + </script> 90 + {{ end }}
+167
appview/pages/templates/goodfirstissues/index.html
··· 1 + {{ define "title" }}good first issues{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="good first issues · tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.org/goodfirstissues" /> 7 + <meta property="og:description" content="Find good first issues to contribute to open source projects" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-10"> 12 + <header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8"> 13 + <h1 class="scale-150 dark:text-white mb-4"> 14 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + Find beginner-friendly issues across all repositories to get started with open source contributions. 18 + </p> 19 + </header> 20 + 21 + <div class="col-span-full md:col-span-10 space-y-6"> 22 + {{ if eq (len .RepoGroups) 0 }} 23 + <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 24 + <div class="text-center py-16"> 25 + <div class="text-gray-500 dark:text-gray-400 mb-4"> 26 + {{ i "circle-dot" "w-16 h-16 mx-auto" }} 27 + </div> 28 + <h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3> 29 + <p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto"> 30 + There are currently no open issues labeled as "good-first-issue" across all repositories. 31 + </p> 32 + <p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto"> 33 + Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started. 34 + </p> 35 + </div> 36 + </div> 37 + {{ else }} 38 + {{ range .RepoGroups }} 39 + <div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 40 + <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap"> 41 + <div class="font-medium dark:text-white flex items-center justify-between"> 42 + <div class="flex items-center min-w-0 flex-1 mr-2"> 43 + {{ if .Repo.Source }} 44 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 45 + {{ else }} 46 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 47 + {{ end }} 48 + {{ $repoOwner := resolve .Repo.Did }} 49 + <a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 50 + </div> 51 + </div> 52 + 53 + 54 + {{ if .Repo.RepoStats }} 55 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4"> 56 + {{ with .Repo.RepoStats.Language }} 57 + <div class="flex gap-2 items-center text-sm"> 58 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 59 + <span>{{ . }}</span> 60 + </div> 61 + {{ end }} 62 + {{ with .Repo.RepoStats.StarCount }} 63 + <div class="flex gap-1 items-center text-sm"> 64 + {{ i "star" "w-3 h-3 fill-current" }} 65 + <span>{{ . }}</span> 66 + </div> 67 + {{ end }} 68 + {{ with .Repo.RepoStats.IssueCount.Open }} 69 + <div class="flex gap-1 items-center text-sm"> 70 + {{ i "circle-dot" "w-3 h-3" }} 71 + <span>{{ . }}</span> 72 + </div> 73 + {{ end }} 74 + {{ with .Repo.RepoStats.PullCount.Open }} 75 + <div class="flex gap-1 items-center text-sm"> 76 + {{ i "git-pull-request" "w-3 h-3" }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{ end }} 80 + </div> 81 + {{ end }} 82 + </div> 83 + 84 + {{ with .Repo.Description }} 85 + <div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 86 + {{ . | description }} 87 + </div> 88 + {{ end }} 89 + 90 + {{ if gt (len .Issues) 0 }} 91 + <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 92 + {{ range .Issues }} 93 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 94 + <div class="py-2 px-6"> 95 + <div class="flex-grow min-w-0 w-full"> 96 + <div class="flex text-sm items-center justify-between w-full"> 97 + <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 98 + <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 99 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 100 + {{ .Title | description }} 101 + </span> 102 + </div> 103 + <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 + <span> 105 + <div class="inline-flex items-center gap-1"> 106 + {{ i "message-square" "w-3 h-3" }} 107 + {{ len .Comments }} 108 + </div> 109 + </span> 110 + <span class="before:content-['·'] before:select-none"></span> 111 + <span class="text-sm"> 112 + {{ template "repo/fragments/shortTimeAgo" .Created }} 113 + </span> 114 + <div class="hidden md:inline-flex md:gap-1"> 115 + {{ $labelState := .Labels }} 116 + {{ range $k, $d := $.LabelDefs }} 117 + {{ range $v, $s := $labelState.GetValSet $d.AtUri.String }} 118 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 119 + {{ end }} 120 + {{ end }} 121 + </div> 122 + </div> 123 + </div> 124 + </div> 125 + </div> 126 + </a> 127 + {{ end }} 128 + </div> 129 + {{ end }} 130 + </div> 131 + {{ end }} 132 + 133 + {{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }} 134 + <div class="flex justify-center mt-8"> 135 + <div class="flex gap-2"> 136 + {{ if gt .Page.Offset 0 }} 137 + {{ $prev := .Page.Previous }} 138 + <a 139 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 140 + hx-boost="true" 141 + href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 142 + > 143 + {{ i "chevron-left" "w-4 h-4" }} 144 + previous 145 + </a> 146 + {{ else }} 147 + <div></div> 148 + {{ end }} 149 + 150 + {{ if eq (len .RepoGroups) .Page.Limit }} 151 + {{ $next := .Page.Next }} 152 + <a 153 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 154 + hx-boost="true" 155 + href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 156 + > 157 + next 158 + {{ i "chevron-right" "w-4 h-4" }} 159 + </a> 160 + {{ end }} 161 + </div> 162 + </div> 163 + {{ end }} 164 + {{ end }} 165 + </div> 166 + </div> 167 + {{ end }}
-9
appview/pages/templates/knots/fragments/banner.html
··· 1 - {{ define "knots/fragments/banner" }} 2 - <div class="w-full px-6 py-2 -z-15 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800 rounded-b drop-shadow-sm"> 3 - A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }}) 4 - that you administer is presently read-only. Consider upgrading this knot to 5 - continue creating repositories on it. 6 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.7.0.md">Click to read the upgrade guide</a>. 7 - </div> 8 - {{ end }} 9 -
+2 -2
appview/pages/templates/knots/fragments/knotListing.html
··· 36 36 </span> 37 37 {{ template "knots/fragments/addMemberModal" . }} 38 38 {{ block "knotDeleteButton" . }} {{ end }} 39 - {{ else if .IsReadOnly }} 39 + {{ else if .IsNeedsUpgrade }} 40 40 <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 - {{ i "shield-alert" "w-4 h-4" }} read-only 41 + {{ i "shield-alert" "w-4 h-4" }} needs upgrade 42 42 </span> 43 43 {{ block "knotRetryButton" . }} {{ end }} 44 44 {{ block "knotDeleteButton" . }} {{ end }}
+12 -10
appview/pages/templates/knots/index.html
··· 1 1 {{ define "title" }}knots{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 5 <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md">docs</a> 9 + </span> 6 10 </div> 7 11 8 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 15 19 {{ end }} 16 20 17 21 {{ define "about" }} 18 - <section class="rounded flex flex-col gap-2"> 19 - <p class="dark:text-gray-300"> 20 - Knots are lightweight headless servers that enable users to host Git repositories with ease. 21 - Knots are designed for either single or multi-tenant use which is perfect for self-hosting on a Raspberry Pi at home, or larger “community” servers. 22 - When creating a repository, you can choose a knot to store it on. 23 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md"> 24 - Checkout the documentation if you're interested in self-hosting. 25 - </a> 22 + <section class="rounded"> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Knots are lightweight headless servers that enable users to host Git repositories with ease. 25 + When creating a repository, you can choose a knot to store it on. 26 26 </p> 27 - </section> 27 + 28 + 29 + </section> 28 30 {{ end }} 29 31 30 32 {{ define "list" }}
+39
appview/pages/templates/labels/fragments/label.html
··· 1 + {{ define "labels/fragments/label" }} 2 + {{ $d := .def }} 3 + {{ $v := .val }} 4 + {{ $withPrefix := .withPrefix }} 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 + {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 + 8 + {{ $lhs := printf "%s" $d.Name }} 9 + {{ $rhs := "" }} 10 + 11 + {{ if not $d.ValueType.IsNull }} 12 + {{ if $d.ValueType.IsDidFormat }} 13 + {{ $v = resolve $v }} 14 + {{ end }} 15 + 16 + {{ if not $withPrefix }} 17 + {{ $lhs = "" }} 18 + {{ else }} 19 + {{ $lhs = printf "%s/" $d.Name }} 20 + {{ end }} 21 + 22 + {{ $rhs = printf "%s" $v }} 23 + {{ end }} 24 + 25 + {{ printf "%s%s" $lhs $rhs }} 26 + </span> 27 + {{ end }} 28 + 29 + 30 + {{ define "labelVal" }} 31 + {{ $d := .def }} 32 + {{ $v := .val }} 33 + 34 + {{ if $d.ValueType.IsDidFormat }} 35 + {{ resolve $v }} 36 + {{ else }} 37 + {{ $v }} 38 + {{ end }} 39 + {{ end }}
+6
appview/pages/templates/labels/fragments/labelDef.html
··· 1 + {{ define "labels/fragments/labelDef" }} 2 + <span class="flex items-center gap-2 font-normal normal-case"> 3 + {{ template "repo/fragments/colorBall" (dict "color" .GetColor) }} 4 + {{ .Name }} 5 + </span> 6 + {{ end }}
+39 -19
appview/pages/templates/layouts/base.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 7 + <meta name="description" content="Social coding, but for real this time!"/> 10 8 <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> 11 - <script src="/static/htmx.min.js"></script> 12 - <script src="/static/htmx-ext-ws.min.js"></script> 9 + 10 + <script defer src="/static/htmx.min.js"></script> 11 + <script defer src="/static/htmx-ext-ws.min.js"></script> 12 + 13 + <!-- preconnect to image cdn --> 14 + <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 + <link rel="preconnect" href="https://camo.tangled.sh" /> 16 + 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 20 + <!-- preload main font --> 21 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 22 + 13 23 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 14 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 15 25 {{ block "extrameta" . }}{{ end }} 16 26 </head> 17 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 27 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 28 {{ block "topbarLayout" . }} 19 - <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 - {{ template "layouts/topbar" . }} 29 + <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 30 + 31 + {{ if .LoggedInUser }} 32 + <div id="upgrade-banner" 33 + hx-get="/upgradeBanner" 34 + hx-trigger="load" 35 + hx-swap="innerHTML"> 36 + </div> 37 + {{ end }} 38 + {{ template "layouts/fragments/topbar" . }} 21 39 </header> 22 40 {{ end }} 23 41 24 42 {{ block "mainLayout" . }} 25 - <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 - {{ block "contentLayout" . }} 27 - <main class="col-span-1 md:col-span-8"> 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 28 47 {{ block "content" . }}{{ end }} 29 48 </main> 30 - {{ end }} 31 - 32 - {{ block "contentAfterLayout" . }} 33 - <main class="col-span-1 md:col-span-8"> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 34 53 {{ block "contentAfter" . }}{{ end }} 35 54 </main> 36 - {{ end }} 55 + {{ end }} 56 + </div> 37 57 </div> 38 58 {{ end }} 39 59 40 60 {{ block "footerLayout" . }} 41 - <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 42 - {{ template "layouts/footer" . }} 61 + <footer class="mt-12"> 62 + {{ template "layouts/fragments/footer" . }} 43 63 </footer> 44 64 {{ end }} 45 65 </body>
-48
appview/pages/templates/layouts/footer.html
··· 1 - {{ define "layouts/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 - tangled<sub>alpha</sub> 8 - </a> 9 - </div> 10 - 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 - </div> 20 - 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 - </div> 27 - 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 - </div> 34 - 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 - <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 - </div> 40 - </div> 41 - 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 - </div> 45 - </div> 46 - </div> 47 - </div> 48 - {{ end }}
+102
appview/pages/templates/layouts/fragments/footer.html
··· 1 + {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 13 + 14 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 + 18 + <!-- Center section with max-width --> 19 + <div class="grid grid-cols-4 gap-2"> 20 + <div class="flex flex-col gap-1"> 21 + <div class="{{ $headerStyle }}">legal</div> 22 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 + </div> 25 + 26 + <div class="flex flex-col gap-1"> 27 + <div class="{{ $headerStyle }}">resources</div> 28 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 + </div> 33 + 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 46 + </div> 47 + 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 51 + </div> 52 + </div> 53 + 54 + <!-- Mobile layout: stacked --> 55 + <div class="lg:hidden flex flex-col gap-8"> 56 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 + 60 + <div class="mb-4 md:mb-0"> 61 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 + {{ template "fragments/logotypeSmall" }} 63 + </a> 64 + </div> 65 + 66 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 + <div class="flex flex-col gap-1"> 68 + <div class="{{ $headerStyle }}">legal</div> 69 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 + </div> 72 + 73 + <div class="flex flex-col gap-1"> 74 + <div class="{{ $headerStyle }}">resources</div> 75 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 + </div> 80 + 81 + <div class="flex flex-col gap-1"> 82 + <div class="{{ $headerStyle }}">social</div> 83 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 + </div> 87 + 88 + <div class="flex flex-col gap-1"> 89 + <div class="{{ $headerStyle }}">contact</div> 90 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 + </div> 93 + </div> 94 + 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 98 + </div> 99 + </div> 100 + </div> 101 + </div> 102 + {{ end }}
+90
appview/pages/templates/layouts/fragments/topbar.html
··· 1 + {{ define "layouts/fragments/topbar" }} 2 + <nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 + <div class="flex justify-between p-0 items-center"> 4 + <div id="left-items"> 5 + <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 + {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 + <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 + alpha 10 + </span> 11 + </a> 12 + </div> 13 + 14 + <div id="right-items" class="flex items-center gap-4"> 15 + {{ with .LoggedInUser }} 16 + {{ block "newButton" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 18 + {{ block "dropDown" . }} {{ end }} 19 + {{ else }} 20 + <a href="/login">login</a> 21 + <span class="text-gray-500 dark:text-gray-400">or</span> 22 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 23 + join now {{ i "arrow-right" "size-4" }} 24 + </a> 25 + {{ end }} 26 + </div> 27 + </div> 28 + </nav> 29 + {{ end }} 30 + 31 + {{ define "newButton" }} 32 + <details class="relative inline-block text-left nav-dropdown"> 33 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 35 + </summary> 36 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 37 + <a href="/repo/new" class="flex items-center gap-2"> 38 + {{ i "book-plus" "w-4 h-4" }} 39 + new repository 40 + </a> 41 + <a href="/strings/new" class="flex items-center gap-2"> 42 + {{ i "line-squiggle" "w-4 h-4" }} 43 + new string 44 + </a> 45 + </div> 46 + </details> 47 + {{ end }} 48 + 49 + {{ define "dropDown" }} 50 + <details class="relative inline-block text-left nav-dropdown"> 51 + <summary 52 + class="cursor-pointer list-none flex items-center gap-1" 53 + > 54 + {{ $user := .Did }} 55 + <img 56 + src="{{ tinyAvatar $user }}" 57 + alt="" 58 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 59 + /> 60 + <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 61 + </summary> 62 + <div 63 + class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 64 + > 65 + <a href="/{{ $user }}">profile</a> 66 + <a href="/{{ $user }}?tab=repos">repositories</a> 67 + <a href="/{{ $user }}?tab=strings">strings</a> 68 + <a href="/knots">knots</a> 69 + <a href="/spindles">spindles</a> 70 + <a href="/settings">settings</a> 71 + <a href="#" 72 + hx-post="/logout" 73 + hx-swap="none" 74 + class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 75 + logout 76 + </a> 77 + </div> 78 + </details> 79 + 80 + <script> 81 + document.addEventListener('click', function(event) { 82 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 83 + dropdowns.forEach(function(dropdown) { 84 + if (!dropdown.contains(event.target)) { 85 + dropdown.removeAttribute('open'); 86 + } 87 + }); 88 + }); 89 + </script> 90 + {{ end }}
+108
appview/pages/templates/layouts/profilebase.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ template "profileTabs" . }} 12 + <section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white drop-shadow-sm"> 13 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 14 + {{ $style := "hidden md:block md:col-span-3" }} 15 + {{ if eq $.Active "overview" }} 16 + {{ $style = "md:col-span-3" }} 17 + {{ end }} 18 + <div class="{{ $style }} order-1 order-1"> 19 + <div class="flex flex-col gap-4"> 20 + {{ template "user/fragments/profileCard" .Card }} 21 + {{ block "punchcard" .Card.Punchcard }} {{ end }} 22 + </div> 23 + </div> 24 + 25 + {{ block "profileContent" . }} {{ end }} 26 + </div> 27 + </section> 28 + {{ end }} 29 + 30 + {{ define "profileTabs" }} 31 + <nav class="w-full pl-4 overflow-x-auto overflow-y-hidden"> 32 + <div class="flex z-60"> 33 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 34 + {{ $tabs := .Card.GetTabs }} 35 + {{ $tabmeta := dict "x" "y" }} 36 + {{ range $item := $tabs }} 37 + {{ $key := index $item 0 }} 38 + {{ $value := index $item 1 }} 39 + {{ $icon := index $item 2 }} 40 + {{ $meta := index $item 3 }} 41 + <a 42 + href="?tab={{ $value }}" 43 + class="relative -mr-px group no-underline hover:no-underline" 44 + hx-boost="true"> 45 + <div 46 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 47 + {{ if eq $.Active $key }} 48 + {{ $activeTabStyles }} 49 + {{ else }} 50 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 51 + {{ end }} 52 + "> 53 + <span class="flex items-center justify-center"> 54 + {{ i $icon "w-4 h-4 mr-2" }} 55 + {{ $key }} 56 + {{ if $meta }} 57 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 58 + {{ end }} 59 + </span> 60 + </div> 61 + </a> 62 + {{ end }} 63 + </div> 64 + </nav> 65 + {{ end }} 66 + 67 + {{ define "punchcard" }} 68 + {{ $now := now }} 69 + <div> 70 + <p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white"> 71 + PUNCHCARD 72 + <span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 "> 73 + {{ .Total | int64 | commaFmt }} commits 74 + </span> 75 + </p> 76 + <div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full"> 77 + {{ range .Punches }} 78 + {{ $count := .Count }} 79 + {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 80 + {{ if lt $count 1 }} 81 + {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 82 + {{ else if lt $count 2 }} 83 + {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 84 + {{ else if lt $count 4 }} 85 + {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 86 + {{ else if lt $count 8 }} 87 + {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 88 + {{ else }} 89 + {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 90 + {{ end }} 91 + 92 + {{ if .Date.After $now }} 93 + {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 94 + {{ end }} 95 + <div class="w-full h-full flex justify-center items-center"> 96 + <div 97 + class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 98 + title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 99 + </div> 100 + </div> 101 + {{ end }} 102 + </div> 103 + </div> 104 + {{ end }} 105 + 106 + {{ define "layouts/profilebase" }} 107 + {{ template "layouts/base" . }} 108 + {{ end }}
+8 -14
appview/pages/templates/layouts/repobase.html
··· 41 41 {{ template "repo/fragments/repoDescription" . }} 42 42 </section> 43 43 44 - <section 45 - class="w-full flex flex-col drop-shadow-sm" 46 - > 44 + <section class="w-full flex flex-col" > 47 45 <nav class="w-full pl-4 overflow-auto"> 48 46 <div class="flex z-60"> 49 47 {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} ··· 71 69 <span class="flex items-center justify-center"> 72 70 {{ i $icon "w-4 h-4 mr-2" }} 73 71 {{ $key }} 74 - {{ if not (isNil $meta) }} 75 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 72 + {{ if $meta }} 73 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 76 74 {{ end }} 77 75 </span> 78 76 </div> ··· 80 78 {{ end }} 81 79 </div> 82 80 </nav> 83 - <section 84 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 85 - > 81 + {{ block "repoContentLayout" . }} 82 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 86 83 {{ block "repoContent" . }}{{ end }} 87 - </section> 88 - {{ block "repoAfter" . }}{{ end }} 84 + </section> 85 + {{ block "repoAfter" . }}{{ end }} 86 + {{ end }} 89 87 </section> 90 88 {{ end }} 91 - 92 - {{ define "layouts/repobase" }} 93 - {{ template "layouts/base" . }} 94 - {{ end }}
-87
appview/pages/templates/layouts/topbar.html
··· 1 - {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 - <div class="flex justify-between p-0 items-center"> 4 - <div id="left-items"> 5 - <a href="/" hx-boost="true" class="flex gap-2 font-bold italic"> 6 - tangled<sub>alpha</sub> 7 - </a> 8 - </div> 9 - 10 - <div id="right-items" class="flex items-center gap-2"> 11 - {{ with .LoggedInUser }} 12 - {{ block "newButton" . }} {{ end }} 13 - {{ block "dropDown" . }} {{ end }} 14 - {{ else }} 15 - <a href="/login">login</a> 16 - <span class="text-gray-500 dark:text-gray-400">or</span> 17 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 - join now {{ i "arrow-right" "size-4" }} 19 - </a> 20 - {{ end }} 21 - </div> 22 - </div> 23 - </nav> 24 - {{ if .LoggedInUser }} 25 - <div id="upgrade-banner" 26 - hx-get="/knots/upgradeBanner" 27 - hx-trigger="load" 28 - hx-swap="innerHTML"> 29 - </div> 30 - {{ end }} 31 - {{ end }} 32 - 33 - {{ define "newButton" }} 34 - <details class="relative inline-block text-left nav-dropdown"> 35 - <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 36 - {{ i "plus" "w-4 h-4" }} new 37 - </summary> 38 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 39 - <a href="/repo/new" class="flex items-center gap-2"> 40 - {{ i "book-plus" "w-4 h-4" }} 41 - new repository 42 - </a> 43 - <a href="/strings/new" class="flex items-center gap-2"> 44 - {{ i "line-squiggle" "w-4 h-4" }} 45 - new string 46 - </a> 47 - </div> 48 - </details> 49 - {{ end }} 50 - 51 - {{ define "dropDown" }} 52 - <details class="relative inline-block text-left nav-dropdown"> 53 - <summary 54 - class="cursor-pointer list-none flex items-center" 55 - > 56 - {{ $user := didOrHandle .Did .Handle }} 57 - {{ template "user/fragments/picHandle" $user }} 58 - </summary> 59 - <div 60 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 61 - > 62 - <a href="/{{ $user }}">profile</a> 63 - <a href="/{{ $user }}?tab=repos">repositories</a> 64 - <a href="/strings/{{ $user }}">strings</a> 65 - <a href="/knots">knots</a> 66 - <a href="/spindles">spindles</a> 67 - <a href="/settings">settings</a> 68 - <a href="#" 69 - hx-post="/logout" 70 - hx-swap="none" 71 - class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 72 - logout 73 - </a> 74 - </div> 75 - </details> 76 - 77 - <script> 78 - document.addEventListener('click', function(event) { 79 - const dropdowns = document.querySelectorAll('.nav-dropdown'); 80 - dropdowns.forEach(function(dropdown) { 81 - if (!dropdown.contains(event.target)) { 82 - dropdown.removeAttribute('open'); 83 - } 84 - }); 85 - }); 86 - </script> 87 - {{ end }}
+13 -128
appview/pages/templates/legal/privacy.html
··· 1 - {{ define "title" }} privacy policy {{ end }} 2 - {{ define "content" }} 3 - <div class="max-w-4xl mx-auto px-4 py-8"> 4 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 - <div class="prose prose-gray dark:prose-invert max-w-none"> 6 - <h1>Privacy Policy</h1> 7 - 8 - <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 9 - 10 - <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p> 1 + {{ define "title" }}privacy policy{{ end }} 11 2 12 - <h2>1. Information We Collect</h2> 13 - 14 - <h3>Account Information</h3> 15 - <p>When you create an account, we collect:</p> 16 - <ul> 17 - <li>Your chosen username</li> 18 - <li>Email address</li> 19 - <li>Profile information you choose to provide</li> 20 - <li>Authentication data</li> 21 - </ul> 3 + {{ define "content" }} 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Learn how we collect, use, and protect your personal information. 9 + </p> 10 + </header> 22 11 23 - <h3>Content and Activity</h3> 24 - <p>We store:</p> 25 - <ul> 26 - <li>Code repositories and associated metadata</li> 27 - <li>Issues, pull requests, and comments</li> 28 - <li>Activity logs and usage patterns</li> 29 - <li>Public keys for authentication</li> 30 - </ul> 31 - 32 - <h2>2. Data Location and Hosting</h2> 33 - <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6"> 34 - <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3> 35 - <p class="text-blue-700 dark:text-blue-300"> 36 - <strong>All Tangled service data is hosted within the European Union.</strong> Specifically: 37 - </p> 38 - <ul class="text-blue-700 dark:text-blue-300 mt-2"> 39 - <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li> 40 - <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li> 41 - <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li> 42 - </ul> 43 - </div> 44 - 45 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6"> 46 - <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3> 47 - <p class="text-yellow-700 dark:text-yellow-300"> 48 - <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure. 49 - </p> 50 - </div> 51 - 52 - <h2>3. Third-Party Data Processors</h2> 53 - <p>We only share your data with the following third-party processors:</p> 54 - 55 - <h3>Resend (Email Services)</h3> 56 - <ul> 57 - <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li> 58 - <li><strong>Data Shared:</strong> Email address and necessary message content</li> 59 - <li><strong>Location:</strong> EU-compliant email delivery service</li> 60 - </ul> 61 - 62 - <h3>Cloudflare (Image Caching)</h3> 63 - <ul> 64 - <li><strong>Purpose:</strong> Caching and optimizing image delivery</li> 65 - <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li> 66 - <li><strong>Location:</strong> Global CDN with EU data protection compliance</li> 67 - </ul> 68 - 69 - <h2>4. How We Use Your Information</h2> 70 - <p>We use your information to:</p> 71 - <ul> 72 - <li>Provide and maintain the Service</li> 73 - <li>Process your transactions and requests</li> 74 - <li>Send you technical notices and support messages</li> 75 - <li>Improve and develop new features</li> 76 - <li>Ensure security and prevent fraud</li> 77 - <li>Comply with legal obligations</li> 78 - </ul> 79 - 80 - <h2>5. Data Sharing and Disclosure</h2> 81 - <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p> 82 - <ul> 83 - <li>With the third-party processors listed above</li> 84 - <li>When required by law or legal process</li> 85 - <li>To protect our rights, property, or safety, or that of our users</li> 86 - <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li> 87 - </ul> 88 - 89 - <h2>6. Data Security</h2> 90 - <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p> 91 - 92 - <h2>7. Data Retention</h2> 93 - <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p> 94 - 95 - <h2>8. Your Rights</h2> 96 - <p>Under applicable data protection laws, you have the right to:</p> 97 - <ul> 98 - <li>Access your personal information</li> 99 - <li>Correct inaccurate information</li> 100 - <li>Request deletion of your information</li> 101 - <li>Object to processing of your information</li> 102 - <li>Data portability</li> 103 - <li>Withdraw consent (where applicable)</li> 104 - </ul> 105 - 106 - <h2>9. Cookies and Tracking</h2> 107 - <p>We use cookies and similar technologies to:</p> 108 - <ul> 109 - <li>Maintain your login session</li> 110 - <li>Remember your preferences</li> 111 - <li>Analyze usage patterns to improve the Service</li> 112 - </ul> 113 - <p>You can control cookie settings through your browser preferences.</p> 114 - 115 - <h2>10. Children's Privacy</h2> 116 - <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p> 117 - 118 - <h2>11. International Data Transfers</h2> 119 - <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p> 120 - 121 - <h2>12. Changes to This Privacy Policy</h2> 122 - <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p> 123 - 124 - <h2>13. Contact Information</h2> 125 - <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p> 126 - 127 - <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 128 - <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 - </div> 130 - </div> 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 131 15 </div> 16 + </main> 132 17 </div> 133 18 {{ end }}
+11 -64
appview/pages/templates/legal/terms.html
··· 1 1 {{ define "title" }}terms of service{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - <h1>Terms of Service</h1> 8 - 9 - <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 10 - 11 - <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p> 12 - 13 - <h2>1. Acceptance of Terms</h2> 14 - <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p> 15 - 16 - <h2>2. Account Registration</h2> 17 - <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p> 18 - 19 - <h2>3. Account Termination</h2> 20 - <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6"> 21 - <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3> 22 - <p class="text-red-700 dark:text-red-300"> 23 - <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users. 24 - </p> 25 - <p class="text-red-700 dark:text-red-300 mt-2"> 26 - Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion. 27 - </p> 28 - </div> 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + A few things you should know. 9 + </p> 10 + </header> 29 11 30 - <h2>4. Acceptable Use</h2> 31 - <p>You agree not to use the Service to:</p> 32 - <ul> 33 - <li>Violate any applicable laws or regulations</li> 34 - <li>Infringe upon the rights of others</li> 35 - <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li> 36 - <li>Engage in spam, phishing, or other deceptive practices</li> 37 - <li>Attempt to gain unauthorized access to the Service or other users' accounts</li> 38 - <li>Interfere with or disrupt the Service or servers connected to the Service</li> 39 - </ul> 40 - 41 - <h2>5. Content and Intellectual Property</h2> 42 - <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p> 43 - 44 - <h2>6. Privacy</h2> 45 - <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p> 46 - 47 - <h2>7. Disclaimers</h2> 48 - <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p> 49 - 50 - <h2>8. Limitation of Liability</h2> 51 - <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p> 52 - 53 - <h2>9. Indemnification</h2> 54 - <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p> 55 - 56 - <h2>10. Governing Law</h2> 57 - <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p> 58 - 59 - <h2>11. Changes to Terms</h2> 60 - <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p> 61 - 62 - <h2>12. Contact Information</h2> 63 - <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p> 64 - 65 - <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 66 - <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 - </div> 68 - </div> 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 69 15 </div> 16 + </main> 70 17 </div> 71 18 {{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
··· 1 + {{define "notifications/fragments/bell"}} 2 + <div class="relative" 3 + hx-get="/notifications/count" 4 + hx-target="#notification-count" 5 + hx-trigger="load, every 30s"> 6 + <a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group"> 7 + {{ i "bell" "w-5 h-5" }} 8 + <span id="notification-count"></span> 9 + </a> 10 + </div> 11 + {{end}}
+7
appview/pages/templates/notifications/fragments/count.html
··· 1 + {{define "notifications/fragments/count"}} 2 + {{if and .Count (gt .Count 0)}} 3 + <span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center"> 4 + {{if gt .Count 99}}99+{{else}}{{.Count}}{{end}} 5 + </span> 6 + {{end}} 7 + {{end}}
+81
appview/pages/templates/notifications/fragments/item.html
··· 1 + {{define "notifications/fragments/item"}} 2 + <a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline"> 3 + <div 4 + class=" 5 + w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors 6 + {{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}} 7 + flex gap-2 items-center 8 + "> 9 + {{ template "notificationIcon" . }} 10 + <div class="flex-1 w-full flex flex-col gap-1"> 11 + <span>{{ template "notificationHeader" . }}</span> 12 + <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 13 + </div> 14 + 15 + </div> 16 + </a> 17 + {{end}} 18 + 19 + {{ define "notificationIcon" }} 20 + <div class="flex-shrink-0 max-h-full w-16 h-16 relative"> 21 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" /> 22 + <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10"> 23 + {{ i .Icon "size-3 text-black dark:text-white" }} 24 + </div> 25 + </div> 26 + {{ end }} 27 + 28 + {{ define "notificationHeader" }} 29 + {{ $actor := resolve .ActorDid }} 30 + 31 + <span class="text-black dark:text-white w-fit">{{ $actor }}</span> 32 + {{ if eq .Type "repo_starred" }} 33 + starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span> 34 + {{ else if eq .Type "issue_created" }} 35 + opened an issue 36 + {{ else if eq .Type "issue_commented" }} 37 + commented on an issue 38 + {{ else if eq .Type "issue_closed" }} 39 + closed an issue 40 + {{ else if eq .Type "pull_created" }} 41 + created a pull request 42 + {{ else if eq .Type "pull_commented" }} 43 + commented on a pull request 44 + {{ else if eq .Type "pull_merged" }} 45 + merged a pull request 46 + {{ else if eq .Type "pull_closed" }} 47 + closed a pull request 48 + {{ else if eq .Type "followed" }} 49 + followed you 50 + {{ else }} 51 + {{ end }} 52 + {{ end }} 53 + 54 + {{ define "notificationSummary" }} 55 + {{ if eq .Type "repo_starred" }} 56 + <!-- no summary --> 57 + {{ else if .Issue }} 58 + #{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 59 + {{ else if .Pull }} 60 + #{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 61 + {{ else if eq .Type "followed" }} 62 + <!-- no summary --> 63 + {{ else }} 64 + {{ end }} 65 + {{ end }} 66 + 67 + {{ define "notificationUrl" }} 68 + {{ $url := "" }} 69 + {{ if eq .Type "repo_starred" }} 70 + {{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 71 + {{ else if .Issue }} 72 + {{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 73 + {{ else if .Pull }} 74 + {{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 75 + {{ else if eq .Type "followed" }} 76 + {{$url = printf "/%s" (resolve .ActorDid)}} 77 + {{ else }} 78 + {{ end }} 79 + 80 + {{ $url }} 81 + {{ end }}
+65
appview/pages/templates/notifications/list.html
··· 1 + {{ define "title" }}notifications{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex items-center justify-between"> 6 + <p class="text-xl font-bold dark:text-white">Notifications</p> 7 + <a href="/settings/notifications" class="flex items-center gap-2"> 8 + {{ i "settings" "w-4 h-4" }} 9 + preferences 10 + </a> 11 + </div> 12 + </div> 13 + 14 + {{if .Notifications}} 15 + <div class="flex flex-col gap-2" id="notifications-list"> 16 + {{range .Notifications}} 17 + {{template "notifications/fragments/item" .}} 18 + {{end}} 19 + </div> 20 + 21 + {{else}} 22 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 23 + <div class="text-center py-12"> 24 + <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 25 + {{ i "bell-off" "w-16 h-16" }} 26 + </div> 27 + <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 28 + <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 29 + </div> 30 + </div> 31 + {{end}} 32 + 33 + {{ template "pagination" . }} 34 + {{ end }} 35 + 36 + {{ define "pagination" }} 37 + <div class="flex justify-end mt-4 gap-2"> 38 + {{ if gt .Page.Offset 0 }} 39 + {{ $prev := .Page.Previous }} 40 + <a 41 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 42 + hx-boost="true" 43 + href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 44 + > 45 + {{ i "chevron-left" "w-4 h-4" }} 46 + previous 47 + </a> 48 + {{ else }} 49 + <div></div> 50 + {{ end }} 51 + 52 + {{ $next := .Page.Next }} 53 + {{ if lt $next.Offset .Total }} 54 + {{ $next := .Page.Next }} 55 + <a 56 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 57 + hx-boost="true" 58 + href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 59 + > 60 + next 61 + {{ i "chevron-right" "w-4 h-4" }} 62 + </a> 63 + {{ end }} 64 + </div> 65 + {{ end }}
+2 -1
appview/pages/templates/repo/blob.html
··· 4 4 {{ template "repo/fragments/meta" . }} 5 5 6 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 7 + {{ $url := printf "https://tangled.org/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 8 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 ··· 78 78 {{ end }} 79 79 </div> 80 80 {{ end }} 81 + {{ template "fragments/multiline-select" }} 81 82 {{ end }}
+2 -2
appview/pages/templates/repo/branches.html
··· 4 4 5 5 {{ define "extrameta" }} 6 6 {{ $title := printf "branches &middot; %s" .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/branches" .RepoInfo.FullName }} 8 - 7 + {{ $url := printf "https://tangled.org/%s/branches" .RepoInfo.FullName }} 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11
+7 -7
appview/pages/templates/repo/commit.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := printf "commit %s &middot; %s" .Diff.Commit.This .RepoInfo.FullName }} 5 - {{ $url := printf "https://tangled.sh/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }} 5 + {{ $url := printf "https://tangled.org/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }} 6 6 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 8 {{ end }} ··· 61 61 {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 62 <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 63 63 </div> 64 - <div class="my-1 pt-2 text-xs border-t"> 64 + <div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700"> 65 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div> 66 66 <div class="break-all">{{ .VerifiedCommit.Fingerprint $commit.This }}</div> 67 67 </div> ··· 80 80 {{end}} 81 81 82 82 {{ define "topbarLayout" }} 83 - <header class="px-1 col-span-full" style="z-index: 20;"> 84 - {{ template "layouts/topbar" . }} 83 + <header class="col-span-full" style="z-index: 20;"> 84 + {{ template "layouts/fragments/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 88 88 {{ define "mainLayout" }} 89 - <div class="px-1 col-span-full flex flex-col gap-4"> 89 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 90 90 {{ block "contentLayout" . }} 91 91 {{ block "content" . }}{{ end }} 92 92 {{ end }} ··· 105 105 {{ end }} 106 106 107 107 {{ define "footerLayout" }} 108 - <footer class="px-1 col-span-full mt-12"> 109 - {{ template "layouts/footer" . }} 108 + <footer class="col-span-full mt-12"> 109 + {{ template "layouts/fragments/footer" . }} 110 110 </footer> 111 111 {{ end }} 112 112
+2 -2
appview/pages/templates/repo/compare/compare.html
··· 12 12 13 13 {{ define "topbarLayout" }} 14 14 <header class="px-1 col-span-full" style="z-index: 20;"> 15 - {{ template "layouts/topbar" . }} 15 + {{ template "layouts/fragments/topbar" . }} 16 16 </header> 17 17 {{ end }} 18 18 ··· 37 37 38 38 {{ define "footerLayout" }} 39 39 <footer class="px-1 col-span-full mt-12"> 40 - {{ template "layouts/footer" . }} 40 + {{ template "layouts/fragments/footer" . }} 41 41 </footer> 42 42 {{ end }} 43 43
+8 -1
appview/pages/templates/repo/fork.html
··· 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 + 10 + <fieldset class="space-y-3"> 11 + <legend for="repo_name" class="dark:text-white">Repository name</legend> 12 + <input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}" 13 + class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" /> 14 + </fieldset> 15 + 9 16 <fieldset class="space-y-3"> 10 17 <legend class="dark:text-white">Select a knot to fork into</legend> 11 18 <div class="space-y-2"> ··· 19 26 class="mr-2" 20 27 id="domain-{{ . }}" 21 28 /> 22 - <span class="dark:text-white">{{ . }}</span> 29 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 23 30 </div> 24 31 {{ else }} 25 32 <p class="dark:text-white">No knots available.</p>
+3 -3
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 2 {{ $knot := .RepoInfo.Knot }} 3 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 5 {{ end }} 6 6 7 7 <details id="clone-dropdown" class="relative inline-block text-left group"> ··· 29 29 <code 30 30 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 31 onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 32 + data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 34 <button 35 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 36 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+6
appview/pages/templates/repo/fragments/colorBall.html
··· 1 + {{ define "repo/fragments/colorBall" }} 2 + <div 3 + class="size-2 rounded-full {{ .classes }}" 4 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ .color }} 70%, white), {{ .color }} 30%, color-mix(in srgb, {{ .color }} 85%, black));" 5 + ></div> 6 + {{ end }}
+6
appview/pages/templates/repo/fragments/diff.html
··· 11 11 {{ $last := sub (len $diff) 1 }} 12 12 13 13 <div class="flex flex-col gap-4"> 14 + {{ if eq (len $diff) 0 }} 15 + <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 16 + <p>No differences found between the selected revisions.</p> 17 + </div> 18 + {{ else }} 14 19 {{ range $idx, $hunk := $diff }} 15 20 {{ with $hunk }} 16 21 <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> ··· 49 54 </div> 50 55 </details> 51 56 {{ end }} 57 + {{ end }} 52 58 {{ end }} 53 59 </div> 54 60 {{ end }}
+4
appview/pages/templates/repo/fragments/duration.html
··· 1 + {{ define "repo/fragments/duration" }} 2 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 3 + {{ end }} 4 +
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
··· 1 + {{ define "repo/fragments/editLabelPanel" }} 2 + <form 3 + id="edit-label-panel" 4 + hx-put="/{{ .RepoInfo.FullName }}/labels/perform" 5 + hx-indicator="#spinner" 6 + hx-disabled-elt="#save-btn,#cancel-btn" 7 + hx-swap="none" 8 + class="flex flex-col gap-6" 9 + > 10 + <input type="hidden" name="repo" value="{{ .RepoInfo.RepoAt }}"> 11 + <input type="hidden" name="subject" value="{{ .Subject }}"> 12 + {{ template "editBasicLabels" . }} 13 + {{ template "editKvLabels" . }} 14 + {{ template "editLabelPanelActions" . }} 15 + <div id="add-label-error" class="text-red-500 dark:text-red-400"></div> 16 + </form> 17 + {{ end }} 18 + 19 + {{ define "editBasicLabels" }} 20 + {{ $defs := .Defs }} 21 + {{ $subject := .Subject }} 22 + {{ $state := .State }} 23 + {{ $labelStyle := "flex items-center gap-2 rounded py-1 px-2 border border-gray-200 dark:border-gray-700 text-sm bg-white dark:bg-gray-800 text-black dark:text-white" }} 24 + <div> 25 + {{ template "repo/fragments/labelSectionHeaderText" "Labels" }} 26 + 27 + <div class="flex gap-1 items-center flex-wrap"> 28 + {{ range $k, $d := $defs }} 29 + {{ $isChecked := $state.ContainsLabel $k }} 30 + {{ if $d.ValueType.IsNull }} 31 + {{ $fieldName := $d.AtUri }} 32 + <label class="{{$labelStyle}}"> 33 + <input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}> 34 + {{ template "labels/fragments/labelDef" $d }} 35 + </label> 36 + {{ end }} 37 + {{ else }} 38 + <p class="text-gray-500 dark:text-gray-400 text-sm py-1"> 39 + No labels defined yet. You can choose default labels or define custom 40 + labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>. 41 + </p> 42 + {{ end }} 43 + </div> 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "editKvLabels" }} 48 + {{ $defs := .Defs }} 49 + {{ $subject := .Subject }} 50 + {{ $state := .State }} 51 + {{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-0" }} 52 + 53 + {{ range $k, $d := $defs }} 54 + {{ if (not $d.ValueType.IsNull) }} 55 + {{ $fieldName := $d.AtUri }} 56 + {{ $valset := $state.GetValSet $k }} 57 + <div id="label-{{$d.Id}}" class="flex flex-col gap-1"> 58 + {{ template "repo/fragments/labelSectionHeaderText" $d.Name }} 59 + {{ if (and $d.Multiple $d.ValueType.IsEnum) }} 60 + <!-- checkbox --> 61 + {{ range $variant := $d.ValueType.Enum }} 62 + <label class="{{$labelStyle}}"> 63 + <input type="checkbox" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}> 64 + {{ $variant }} 65 + </label> 66 + {{ end }} 67 + {{ else if $d.Multiple }} 68 + <!-- dynamically growing input fields --> 69 + {{ range $v, $s := $valset }} 70 + {{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }} 71 + {{ else }} 72 + {{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }} 73 + {{ end }} 74 + {{ template "addFieldButton" $d }} 75 + {{ else if $d.ValueType.IsEnum }} 76 + <!-- radio buttons --> 77 + {{ $isUsed := $state.ContainsLabel $k }} 78 + {{ range $variant := $d.ValueType.Enum }} 79 + <label class="{{$labelStyle}}"> 80 + <input type="radio" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}> 81 + {{ $variant }} 82 + </label> 83 + {{ end }} 84 + <label class="{{$labelStyle}}"> 85 + <input type="radio" name="{{ $fieldName }}" value="" {{ if not $isUsed }}checked{{ end }}> 86 + None 87 + </label> 88 + {{ else }} 89 + <!-- single input field based on value type --> 90 + {{ range $v, $s := $valset }} 91 + {{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }} 92 + {{ else }} 93 + {{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }} 94 + {{ end }} 95 + {{ end }} 96 + </div> 97 + {{ end }} 98 + {{ end }} 99 + {{ end }} 100 + 101 + {{ define "multipleInputField" }} 102 + <div class="flex gap-1 items-stretch"> 103 + {{ template "valueTypeInput" . }} 104 + {{ template "removeFieldButton" }} 105 + </div> 106 + {{ end }} 107 + 108 + {{ define "addFieldButton" }} 109 + <div style="display:none" id="tpl-{{ .Id }}"> 110 + {{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }} 111 + </div> 112 + <button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2"> 113 + {{ i "plus" "size-4" }} add 114 + </button> 115 + {{ end }} 116 + 117 + {{ define "removeFieldButton" }} 118 + <button type="button" onClick="this.parentElement.remove()" class="btn flex items-center gap-2 text-red-400 dark:text-red-500"> 119 + {{ i "trash-2" "size-4" }} 120 + </button> 121 + {{ end }} 122 + 123 + {{ define "valueTypeInput" }} 124 + {{ $def := .def }} 125 + {{ $valueType := $def.ValueType }} 126 + {{ $value := .value }} 127 + {{ $key := .key }} 128 + 129 + {{ if $valueType.IsBool }} 130 + {{ template "boolTypeInput" $ }} 131 + {{ else if $valueType.IsInt }} 132 + {{ template "intTypeInput" $ }} 133 + {{ else if $valueType.IsString }} 134 + {{ template "stringTypeInput" $ }} 135 + {{ else if $valueType.IsNull }} 136 + {{ template "nullTypeInput" $ }} 137 + {{ end }} 138 + {{ end }} 139 + 140 + {{ define "boolTypeInput" }} 141 + {{ $def := .def }} 142 + {{ $fieldName := $def.AtUri }} 143 + {{ $value := .value }} 144 + {{ $labelStyle = "font-normal normal-case flex items-center gap-2" }} 145 + <div class="flex flex-col gap-1"> 146 + <label class="{{$labelStyle}}"> 147 + <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 148 + None 149 + </label> 150 + <label class="{{$labelStyle}}"> 151 + <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 152 + None 153 + </label> 154 + <label class="{{$labelStyle}}"> 155 + <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 156 + None 157 + </label> 158 + </div> 159 + {{ end }} 160 + 161 + {{ define "intTypeInput" }} 162 + {{ $def := .def }} 163 + {{ $fieldName := $def.AtUri }} 164 + {{ $value := .value }} 165 + <input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}"> 166 + {{ end }} 167 + 168 + {{ define "stringTypeInput" }} 169 + {{ $def := .def }} 170 + {{ $fieldName := $def.AtUri }} 171 + {{ $valueType := $def.ValueType }} 172 + {{ $value := .value }} 173 + {{ if $valueType.IsDidFormat }} 174 + {{ $value = trimPrefix (resolve .value) "@" }} 175 + {{ end }} 176 + <input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}"> 177 + {{ end }} 178 + 179 + {{ define "nullTypeInput" }} 180 + {{ $def := .def }} 181 + {{ $fieldName := $def.AtUri }} 182 + <input class="p-1" type="hidden" name="{{$fieldName}}" value="null"> 183 + {{ end }} 184 + 185 + {{ define "editLabelPanelActions" }} 186 + <div class="flex gap-2 pt-2"> 187 + <button 188 + id="cancel-btn" 189 + type="button" 190 + hx-get="/{{ .RepoInfo.FullName }}/label" 191 + hx-vals='{"subject": "{{.Subject}}"}' 192 + hx-swap="outerHTML" 193 + hx-target="#edit-label-panel" 194 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 group"> 195 + {{ i "x" "size-4" }} cancel 196 + </button> 197 + 198 + <button 199 + id="save-btn" 200 + type="submit" 201 + class="btn w-1/2 flex items-center"> 202 + <span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span> 203 + <span id="spinner" class="group"> 204 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 205 + </span> 206 + </button> 207 + </div> 208 + {{ end }}
+43
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 + {{ define "repo/fragments/labelPanel" }} 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 + {{ template "basicLabels" . }} 4 + {{ template "kvLabels" . }} 5 + </div> 6 + {{ end }} 7 + 8 + {{ define "basicLabels" }} 9 + <div> 10 + {{ template "repo/fragments/labelSectionHeader" (dict "Name" "Labels" "RepoInfo" .RepoInfo "Subject" .Subject) }} 11 + 12 + {{ $hasLabel := false }} 13 + <div class="flex gap-1 items-center flex-wrap"> 14 + {{ range $k, $d := .Defs }} 15 + {{ if (and $d.ValueType.IsNull ($.State.ContainsLabel $k)) }} 16 + {{ $hasLabel = true }} 17 + {{ template "labels/fragments/label" (dict "def" $d "val" "") }} 18 + {{ end }} 19 + {{ end }} 20 + 21 + {{ if not $hasLabel }} 22 + <p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }} 27 + 28 + {{ define "kvLabels" }} 29 + {{ range $k, $d := .Defs }} 30 + {{ if (not $d.ValueType.IsNull) }} 31 + <div id="label-{{$d.Id}}"> 32 + {{ template "repo/fragments/labelSectionHeader" (dict "Name" $d.Name "RepoInfo" $.RepoInfo "Subject" $.Subject) }} 33 + <div class="flex gap-1 items-center flex-wrap"> 34 + {{ range $v, $s := $.State.GetValSet $d.AtUri.String }} 35 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" false) }} 36 + {{ else }} 37 + <p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p> 38 + {{ end }} 39 + </div> 40 + </div> 41 + {{ end }} 42 + {{ end }} 43 + {{ end }}
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
··· 1 + {{ define "repo/fragments/labelSectionHeader" }} 2 + 3 + <div class="flex justify-between items-center gap-2"> 4 + {{ template "repo/fragments/labelSectionHeaderText" .Name }} 5 + {{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }} 6 + <a 7 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 8 + hx-get="/{{ .RepoInfo.FullName }}/label/edit" 9 + hx-vals='{"subject": "{{.Subject}}"}' 10 + hx-swap="outerHTML" 11 + hx-target="#label-panel"> 12 + {{ i "pencil" "size-3" }} 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
··· 1 + {{ define "repo/fragments/labelSectionHeaderText" }} 2 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">{{ . }}</span> 3 + {{ end }}
+9 -5
appview/pages/templates/repo/fragments/meta.html
··· 1 1 {{ define "repo/fragments/meta" }} 2 2 <meta 3 3 name="vcs:clone" 4 - content="https://tangled.sh/{{ .RepoInfo.FullName }}" 4 + content="https://tangled.org/{{ .RepoInfo.FullName }}" 5 5 /> 6 6 <meta 7 7 name="forge:summary" 8 - content="https://tangled.sh/{{ .RepoInfo.FullName }}" 8 + content="https://tangled.org/{{ .RepoInfo.FullName }}" 9 9 /> 10 10 <meta 11 11 name="forge:dir" 12 - content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}" 12 + content="https://tangled.org/{{ .RepoInfo.FullName }}/tree/{ref}/{path}" 13 13 /> 14 14 <meta 15 15 name="forge:file" 16 - content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}" 16 + content="https://tangled.org/{{ .RepoInfo.FullName }}/blob/{ref}/{path}" 17 17 /> 18 18 <meta 19 19 name="forge:line" 20 - content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}" 20 + content="https://tangled.org/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}" 21 21 /> 22 22 <meta 23 23 name="go-import" 24 24 content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}" 25 + /> 26 + <meta 27 + name="go-import" 28 + content="tangled.org/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.org/{{ .RepoInfo.FullName }}" 25 29 /> 26 30 {{ end }}
+10 -2
appview/pages/templates/repo/fragments/og.html
··· 1 1 {{ define "repo/fragments/og" }} 2 2 {{ $title := or .Title .RepoInfo.FullName }} 3 3 {{ $description := or .Description .RepoInfo.Description }} 4 - {{ $url := or .Url (printf "https://tangled.sh/%s" .RepoInfo.FullName) }} 5 - 4 + {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }} 6 6 7 7 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 8 <meta property="og:type" content="object" /> 9 9 <meta property="og:url" content="{{ $url }}" /> 10 10 <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 11 19 {{ end }}
+26
appview/pages/templates/repo/fragments/participants.html
··· 1 + {{ define "repo/fragments/participants" }} 2 + {{ $all := . }} 3 + {{ $ps := take $all 5 }} 4 + <div class="px-2 md:px-0"> 5 + <div class="py-1 flex items-center text-sm"> 6 + <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 + </div> 9 + <div class="flex items-center -space-x-3 mt-2"> 10 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 + {{ range $i, $p := $ps }} 12 + <img 13 + src="{{ tinyAvatar . }}" 14 + alt="" 15 + class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 + /> 17 + {{ end }} 18 + 19 + {{ if gt (len $all) 5 }} 20 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 + +{{ sub (len $all) 5 }} 22 + </span> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }}
+6 -1
appview/pages/templates/repo/fragments/reaction.html
··· 2 2 <button 3 3 id="reactIndi-{{ .Kind }}" 4 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 5 + leading-4 px-3 gap-1 relative group 6 6 {{ if eq .Count 0 }} 7 7 hidden 8 8 {{ end }} ··· 20 20 dark:hover:border-gray-600 21 21 {{ end }} 22 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 23 28 {{ if .IsReacted }} 24 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 30 {{ else }}
+24
appview/pages/templates/repo/fragments/readme.html
··· 1 + {{ define "repo/fragments/readme" }} 2 + <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 + {{- if .ReadmeFileName -}} 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 + {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 + <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 + </div> 8 + {{- end -}} 9 + <section 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 14 + {{ end }}" 15 + > 16 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 17 + {{- .Readme -}} 18 + </pre> 19 + {{- else -}} 20 + {{ .HTMLReadme }} 21 + {{- end -}}</article> 22 + </section> 23 + </div> 24 + {{ end }}
+4
appview/pages/templates/repo/fragments/shortTime.html
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
+9
appview/pages/templates/repo/fragments/shortTimeAgo.html
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ $formatted := shortRelTimeFmt . }} 3 + {{ $content := printf "%s ago" $formatted }} 4 + {{ if eq $formatted "now" }} 5 + {{ $content = "now" }} 6 + {{ end }} 7 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" $content) }} 8 + {{ end }} 9 +
-16
appview/pages/templates/repo/fragments/time.html
··· 1 - {{ define "repo/fragments/timeWrapper" }} 2 - <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 - {{ end }} 4 - 5 1 {{ define "repo/fragments/time" }} 6 2 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 3 {{ end }} 8 - 9 - {{ define "repo/fragments/shortTime" }} 10 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 11 - {{ end }} 12 - 13 - {{ define "repo/fragments/shortTimeAgo" }} 14 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 15 - {{ end }} 16 - 17 - {{ define "repo/fragments/duration" }} 18 - <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 19 - {{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+26 -30
appview/pages/templates/repo/index.html
··· 35 35 {{ end }} 36 36 37 37 {{ define "repoLanguages" }} 38 - <div class="flex gap-[1px] -m-6 mb-6 overflow-hidden rounded-t"> 38 + <details class="group -m-6 mb-4"> 39 + <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 40 + {{ range $value := .Languages }} 41 + <div 42 + title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%' 43 + style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%" 44 + ></div> 45 + {{ end }} 46 + </summary> 47 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 39 48 {{ range $value := .Languages }} 40 - <div 41 - title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%' 42 - class="h-[4px] rounded-full" 43 - style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%" 44 - ></div> 49 + <div 50 + class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 51 + > 52 + {{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }} 53 + <div>{{ or $value.Name "Other" }} 54 + <span class="text-gray-500 dark:text-gray-400"> 55 + {{ if lt $value.Percentage 0.05 }} 56 + 0.1% 57 + {{ else }} 58 + {{ printf "%.1f" $value.Percentage }}% 59 + {{ end }} 60 + </span></div> 61 + </div> 45 62 {{ end }} 46 - </div> 63 + </div> 64 + </details> 47 65 {{ end }} 48 - 49 66 50 67 {{ define "branchSelector" }} 51 68 <div class="flex gap-2 items-center justify-between w-full"> ··· 323 340 324 341 {{ define "repoAfter" }} 325 342 {{- if or .HTMLReadme .Readme -}} 326 - <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 327 - {{- if .ReadmeFileName -}} 328 - <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 329 - {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 330 - <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 331 - </div> 332 - {{- end -}} 333 - <section 334 - class="p-6 overflow-auto {{ if not .Raw }} 335 - prose dark:prose-invert dark:[&_pre]:bg-gray-900 336 - dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 337 - dark:[&_pre]:border dark:[&_pre]:border-gray-700 338 - {{ end }}" 339 - > 340 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 341 - {{- .Readme -}} 342 - </pre> 343 - {{- else -}} 344 - {{ .HTMLReadme }} 345 - {{- end -}}</article> 346 - </section> 347 - </div> 343 + {{ template "repo/fragments/readme" . }} 348 344 {{- end -}} 349 345 {{ end }}
+58
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 + {{ define "repo/issues/fragments/commentList" }} 2 + <div class="flex flex-col gap-8"> 3 + {{ range $item := .CommentList }} 4 + {{ template "commentListing" (list $ .) }} 5 + {{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "commentListing" }} 10 + {{ $root := index . 0 }} 11 + {{ $comment := index . 1 }} 12 + {{ $params := 13 + (dict 14 + "RepoInfo" $root.RepoInfo 15 + "LoggedInUser" $root.LoggedInUser 16 + "Issue" $root.Issue 17 + "Comment" $comment.Self) }} 18 + 19 + <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 20 + {{ template "topLevelComment" $params }} 21 + 22 + <div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700"> 23 + {{ range $index, $reply := $comment.Replies }} 24 + <div class="relative "> 25 + <!-- Horizontal connector --> 26 + <div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div> 27 + 28 + <div class="pl-2"> 29 + {{ 30 + template "replyComment" 31 + (dict 32 + "RepoInfo" $root.RepoInfo 33 + "LoggedInUser" $root.LoggedInUser 34 + "Issue" $root.Issue 35 + "Comment" $reply) 36 + }} 37 + </div> 38 + </div> 39 + {{ end }} 40 + </div> 41 + 42 + {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ define "topLevelComment" }} 47 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800"> 48 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 49 + {{ template "repo/issues/fragments/issueCommentBody" . }} 50 + </div> 51 + {{ end }} 52 + 53 + {{ define "replyComment" }} 54 + <div class="p-4 w-full mx-auto overflow-hidden"> 55 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 56 + {{ template "repo/issues/fragments/issueCommentBody" . }} 57 + </div> 58 + {{ end }}
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 2 + <div id="comment-body-{{.Comment.Id}}" class="pt-2"> 3 + <textarea 4 + id="edit-textarea-{{ .Comment.Id }}" 5 + name="body" 6 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 + rows="5" 8 + autofocus>{{ .Comment.Body }}</textarea> 7 9 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['·']"></span> 12 - author 13 - {{ end }} 14 - 15 - <span class="before:content-['·']"></span> 16 - <a 17 - href="#{{ .CommentId }}" 18 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 - id="{{ .CommentId }}"> 20 - {{ template "repo/fragments/time" .Created }} 21 - </a> 22 - 23 - <button 24 - class="btn px-2 py-1 flex items-center gap-2 text-sm group" 25 - hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 26 - hx-include="#edit-textarea-{{ .CommentId }}" 27 - hx-target="#comment-container-{{ .CommentId }}" 28 - hx-swap="outerHTML"> 29 - {{ i "check" "w-4 h-4" }} 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </button> 32 - <button 33 - class="btn px-2 py-1 flex items-center gap-2 text-sm" 34 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 35 - hx-target="#comment-container-{{ .CommentId }}" 36 - hx-swap="outerHTML"> 37 - {{ i "x" "w-4 h-4" }} 38 - </button> 39 - <span id="comment-{{.CommentId}}-status"></span> 40 - </div> 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 41 13 42 - <div> 43 - <textarea 44 - id="edit-textarea-{{ .CommentId }}" 45 - name="body" 46 - class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 47 - </div> 14 + {{ define "editActions" }} 15 + <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 16 + {{ template "cancel" . }} 17 + {{ template "save" . }} 48 18 </div> 49 - {{ end }} 19 + {{ end }} 20 + 21 + {{ define "save" }} 22 + <button 23 + class="btn-create py-0 flex gap-1 items-center group text-sm" 24 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 + hx-include="#edit-textarea-{{ .Comment.Id }}" 26 + hx-target="#comment-body-{{ .Comment.Id }}" 27 + hx-swap="outerHTML"> 28 + {{ i "check" "size-4" }} 29 + save 30 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 + </button> 50 32 {{ end }} 51 33 34 + {{ define "cancel" }} 35 + <button 36 + class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 37 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 38 + hx-target="#comment-body-{{ .Comment.Id }}" 39 + hx-swap="outerHTML"> 40 + {{ i "x" "size-4" }} 41 + cancel 42 + </button> 43 + {{ end }}
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 + {{ define "repo/issues/fragments/globalIssueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2 mb-3"> 6 + <div class="flex items-center gap-3 mb-2"> 7 + <a 8 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 + class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 + > 11 + {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 + </a> 13 + </div> 14 + <a 15 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 + class="no-underline hover:underline" 17 + > 18 + {{ .Title | description }} 19 + <span class="text-gray-500">#{{ .IssueId }}</span> 20 + </a> 21 + </div> 22 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 + {{ $icon := "ban" }} 25 + {{ $state := "closed" }} 26 + {{ if .Open }} 27 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 + {{ $icon = "circle-dot" }} 29 + {{ $state = "open" }} 30 + {{ end }} 31 + 32 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 + <span class="text-white dark:text-white">{{ $state }}</span> 35 + </span> 36 + 37 + <span class="ml-1"> 38 + {{ template "user/fragments/picHandleLink" .Did }} 39 + </span> 40 + 41 + <span class="before:content-['·']"> 42 + {{ template "repo/fragments/time" .Created }} 43 + </span> 44 + 45 + <span class="before:content-['·']"> 46 + {{ $s := "s" }} 47 + {{ if eq (len .Comments) 1 }} 48 + {{ $s = "" }} 49 + {{ end }} 50 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 + </span> 52 + 53 + {{ $state := .Labels }} 54 + {{ range $k, $d := $.LabelDefs }} 55 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 + {{ end }} 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 - {{ define "repo/issues/fragments/issueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 6 - 7 - <!-- show user "hats" --> 8 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 9 - {{ if $isIssueAuthor }} 10 - <span class="before:content-['·']"></span> 11 - author 12 - {{ end }} 13 - 14 - <span class="before:content-['·']"></span> 15 - <a 16 - href="#{{ .CommentId }}" 17 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 18 - id="{{ .CommentId }}"> 19 - {{ if .Deleted }} 20 - deleted {{ template "repo/fragments/time" .Deleted }} 21 - {{ else if .Edited }} 22 - edited {{ template "repo/fragments/time" .Edited }} 23 - {{ else }} 24 - {{ template "repo/fragments/time" .Created }} 25 - {{ end }} 26 - </a> 27 - 28 - {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 29 - {{ if and $isCommentOwner (not .Deleted) }} 30 - <button 31 - class="btn px-2 py-1 text-sm" 32 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 33 - hx-swap="outerHTML" 34 - hx-target="#comment-container-{{.CommentId}}" 35 - > 36 - {{ i "pencil" "w-4 h-4" }} 37 - </button> 38 - <button 39 - class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 40 - hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 41 - hx-confirm="Are you sure you want to delete your comment?" 42 - hx-swap="outerHTML" 43 - hx-target="#comment-container-{{.CommentId}}" 44 - > 45 - {{ i "trash-2" "w-4 h-4" }} 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - {{ end }} 49 - 50 - </div> 51 - {{ if not .Deleted }} 52 - <div class="prose dark:prose-invert"> 53 - {{ .Body | markdown }} 54 - </div> 55 - {{ end }} 56 - </div> 57 - {{ end }} 58 - {{ end }}
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
··· 1 + {{ define "repo/issues/fragments/issueCommentActions" }} 2 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 3 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 4 + <div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2"> 5 + {{ template "edit" . }} 6 + {{ template "delete" . }} 7 + </div> 8 + {{ end }} 9 + {{ end }} 10 + 11 + {{ define "edit" }} 12 + <a 13 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 14 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 15 + hx-swap="outerHTML" 16 + hx-target="#comment-body-{{.Comment.Id}}"> 17 + {{ i "pencil" "size-3" }} 18 + edit 19 + </a> 20 + {{ end }} 21 + 22 + {{ define "delete" }} 23 + <a 24 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 25 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 26 + hx-confirm="Are you sure you want to delete your comment?" 27 + hx-swap="outerHTML" 28 + hx-target="#comment-body-{{.Comment.Id}}" 29 + > 30 + {{ i "trash-2" "size-3" }} 31 + delete 32 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </a> 34 + {{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
··· 1 + {{ define "repo/issues/fragments/issueCommentBody" }} 2 + <div id="comment-body-{{.Comment.Id}}"> 3 + {{ if not .Comment.Deleted }} 4 + <div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div> 5 + {{ else }} 6 + <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 7 + {{ end }} 8 + </div> 9 + {{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 + {{ define "repo/issues/fragments/issueCommentHeader" }} 2 + <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 + {{ template "hats" $ }} 5 + {{ template "timestamp" . }} 6 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 + {{ template "editIssueComment" . }} 9 + {{ template "deleteIssueComment" . }} 10 + {{ end }} 11 + </div> 12 + {{ end }} 13 + 14 + {{ define "hats" }} 15 + {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} 16 + {{ if $isIssueAuthor }} 17 + (author) 18 + {{ end }} 19 + {{ end }} 20 + 21 + {{ define "timestamp" }} 22 + <a href="#{{ .Comment.Id }}" 23 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 24 + id="{{ .Comment.Id }}"> 25 + {{ if .Comment.Deleted }} 26 + {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 + {{ else if .Comment.Edited }} 28 + edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} 29 + {{ else }} 30 + {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} 31 + {{ end }} 32 + </a> 33 + {{ end }} 34 + 35 + {{ define "editIssueComment" }} 36 + <a 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 38 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 + hx-swap="outerHTML" 40 + hx-target="#comment-body-{{.Comment.Id}}"> 41 + {{ i "pencil" "size-3" }} 42 + </a> 43 + {{ end }} 44 + 45 + {{ define "deleteIssueComment" }} 46 + <a 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 48 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 + hx-confirm="Are you sure you want to delete your comment?" 50 + hx-swap="outerHTML" 51 + hx-target="#comment-body-{{.Comment.Id}}" 52 + > 53 + {{ i "trash-2" "size-3" }} 54 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 55 + </a> 56 + {{ end }}
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ $state := .Labels }} 46 + {{ range $k, $d := $.LabelDefs }} 47 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 + {{ end }} 50 + {{ end }} 51 + </div> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }}
+150
appview/pages/templates/repo/issues/fragments/newComment.html
··· 1 + {{ define "repo/issues/fragments/newComment" }} 2 + {{ if .LoggedInUser }} 3 + <form 4 + id="comment-form" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + > 8 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 9 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 10 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 + </div> 12 + <textarea 13 + id="comment-textarea" 14 + name="body" 15 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 16 + placeholder="Add to the discussion. Markdown is supported." 17 + onkeyup="updateCommentForm()" 18 + rows="5" 19 + ></textarea> 20 + <div id="issue-comment"></div> 21 + <div id="issue-action" class="error"></div> 22 + </div> 23 + 24 + <div class="flex gap-2 mt-2"> 25 + <button 26 + id="comment-button" 27 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 28 + type="submit" 29 + hx-disabled-elt="#comment-button" 30 + class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group" 31 + disabled 32 + > 33 + {{ i "message-square-plus" "w-4 h-4" }} 34 + comment 35 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 + </button> 37 + 38 + {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 39 + {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 40 + {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 41 + {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }} 42 + <button 43 + id="close-button" 44 + type="button" 45 + class="btn flex items-center gap-2" 46 + hx-indicator="#close-spinner" 47 + hx-trigger="click" 48 + > 49 + {{ i "ban" "w-4 h-4" }} 50 + close 51 + <span id="close-spinner" class="group"> 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </span> 54 + </button> 55 + <div 56 + id="close-with-comment" 57 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 58 + hx-trigger="click from:#close-button" 59 + hx-disabled-elt="#close-with-comment" 60 + hx-target="#issue-comment" 61 + hx-indicator="#close-spinner" 62 + hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 63 + hx-swap="none" 64 + > 65 + </div> 66 + <div 67 + id="close-issue" 68 + hx-disabled-elt="#close-issue" 69 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 70 + hx-trigger="click from:#close-button" 71 + hx-target="#issue-action" 72 + hx-indicator="#close-spinner" 73 + hx-swap="none" 74 + > 75 + </div> 76 + <script> 77 + document.addEventListener('htmx:configRequest', function(evt) { 78 + if (evt.target.id === 'close-with-comment') { 79 + const commentText = document.getElementById('comment-textarea').value.trim(); 80 + if (commentText === '') { 81 + evt.detail.parameters = {}; 82 + evt.preventDefault(); 83 + } 84 + } 85 + }); 86 + </script> 87 + {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }} 88 + <button 89 + type="button" 90 + class="btn flex items-center gap-2" 91 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 92 + hx-indicator="#reopen-spinner" 93 + hx-swap="none" 94 + > 95 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 96 + reopen 97 + <span id="reopen-spinner" class="group"> 98 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 + </span> 100 + </button> 101 + {{ end }} 102 + 103 + <script> 104 + function updateCommentForm() { 105 + const textarea = document.getElementById('comment-textarea'); 106 + const commentButton = document.getElementById('comment-button'); 107 + const closeButton = document.getElementById('close-button'); 108 + 109 + if (textarea.value.trim() !== '') { 110 + commentButton.removeAttribute('disabled'); 111 + } else { 112 + commentButton.setAttribute('disabled', ''); 113 + } 114 + 115 + if (closeButton) { 116 + if (textarea.value.trim() !== '') { 117 + closeButton.innerHTML = ` 118 + {{ i "ban" "w-4 h-4" }} 119 + <span>close with comment</span> 120 + <span id="close-spinner" class="group"> 121 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 122 + </span>`; 123 + } else { 124 + closeButton.innerHTML = ` 125 + {{ i "ban" "w-4 h-4" }} 126 + <span>close</span> 127 + <span id="close-spinner" class="group"> 128 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 129 + </span>`; 130 + } 131 + } 132 + } 133 + 134 + document.addEventListener('DOMContentLoaded', function() { 135 + updateCommentForm(); 136 + }); 137 + </script> 138 + </div> 139 + </form> 140 + {{ else }} 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 148 + </div> 149 + {{ end }} 150 + {{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 1 + {{ define "repo/issues/fragments/putIssue" }} 2 + <!-- this form is used for new and edit, .Issue is passed when editing --> 3 + <form 4 + {{ if eq .Action "edit" }} 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 6 + {{ else }} 7 + hx-post="/{{ .RepoInfo.FullName }}/issues/new" 8 + {{ end }} 9 + hx-swap="none" 10 + hx-indicator="#spinner"> 11 + <div class="flex flex-col gap-2"> 12 + <div> 13 + <label for="title">title</label> 14 + <input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" /> 15 + </div> 16 + <div> 17 + <label for="body">body</label> 18 + <textarea 19 + name="body" 20 + id="body" 21 + rows="6" 22 + class="w-full resize-y" 23 + placeholder="Describe your issue. Markdown is supported." 24 + >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea> 25 + </div> 26 + <div class="flex justify-between"> 27 + <div id="issues" class="error"></div> 28 + <div class="flex gap-2 items-center"> 29 + <a 30 + class="btn flex items-center gap-2 no-underline hover:no-underline" 31 + type="button" 32 + {{ if .Issue }} 33 + href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}" 34 + {{ else }} 35 + href="/{{ .RepoInfo.FullName }}/issues" 36 + {{ end }} 37 + > 38 + {{ i "x" "w-4 h-4" }} 39 + cancel 40 + </a> 41 + <button type="submit" class="btn-create flex items-center gap-2"> 42 + {{ if eq .Action "edit" }} 43 + {{ i "pencil" "w-4 h-4" }} 44 + {{ .Action }} issue 45 + {{ else }} 46 + {{ i "circle-plus" "w-4 h-4" }} 47 + {{ .Action }} issue 48 + {{ end }} 49 + <span id="spinner" class="group"> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </span> 52 + </button> 53 + </div> 54 + </div> 55 + </div> 56 + </form> 57 + {{ end }}
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
··· 1 + {{ define "repo/issues/fragments/replyComment" }} 2 + <form 3 + class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 + id="reply-form-{{ .Comment.Id }}" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + hx-disabled-elt="#reply-{{ .Comment.Id }}" 8 + > 9 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 10 + <textarea 11 + id="reply-{{.Comment.Id}}-textarea" 12 + name="body" 13 + class="w-full p-2" 14 + placeholder="Leave a reply..." 15 + autofocus 16 + rows="3" 17 + hx-trigger="keydown[ctrlKey&&key=='Enter']" 18 + hx-target="#reply-form-{{ .Comment.Id }}" 19 + hx-get="#" 20 + hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea> 21 + 22 + <input 23 + type="text" 24 + id="reply-to" 25 + name="reply-to" 26 + required 27 + value="{{ .Comment.AtUri }}" 28 + class="hidden" 29 + /> 30 + {{ template "replyActions" . }} 31 + </form> 32 + {{ end }} 33 + 34 + {{ define "replyActions" }} 35 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 36 + {{ template "cancel" . }} 37 + {{ template "reply" . }} 38 + </div> 39 + {{ end }} 40 + 41 + {{ define "cancel" }} 42 + <button 43 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 44 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder" 45 + hx-target="#reply-form-{{ .Comment.Id }}" 46 + hx-swap="outerHTML"> 47 + {{ i "x" "size-4" }} 48 + cancel 49 + </button> 50 + {{ end }} 51 + 52 + {{ define "reply" }} 53 + <button 54 + id="reply-{{ .Comment.Id }}" 55 + type="submit" 56 + class="btn-create flex items-center gap-2 no-underline hover:no-underline"> 57 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + reply 60 + </button> 61 + {{ end }}
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 + {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 + <div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + <img 5 + src="{{ tinyAvatar .LoggedInUser.Did }}" 6 + alt="" 7 + class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 8 + /> 9 + {{ end }} 10 + <input 11 + class="w-full py-2 border-none focus:outline-none" 12 + placeholder="Leave a reply..." 13 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 + hx-trigger="focus" 15 + hx-target="closest div" 16 + hx-swap="outerHTML" 17 + > 18 + </input> 19 + </div> 20 + {{ end }}
+118 -202
appview/pages/templates/repo/issues/issue.html
··· 3 3 4 4 {{ define "extrameta" }} 5 5 {{ $title := printf "%s &middot; issue #%d &middot; %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }} 6 - {{ $url := printf "https://tangled.sh/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 6 + {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 7 7 8 8 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 9 9 {{ end }} 10 10 11 - {{ define "repoContent" }} 12 - <header class="pb-4"> 13 - <h1 class="text-2xl"> 14 - {{ .Issue.Title | description }} 15 - <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 - </h1> 17 - </header> 18 - 19 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 20 - {{ $icon := "ban" }} 21 - {{ if eq .State "open" }} 22 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 23 - {{ $icon = "circle-dot" }} 24 - {{ end }} 25 - 26 - <section class="mt-2"> 27 - <div class="inline-flex items-center gap-2"> 28 - <div id="state" 29 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 30 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 31 - <span class="text-white">{{ .State }}</span> 32 - </div> 33 - <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 34 - opened by 35 - {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 36 - {{ template "user/fragments/picHandleLink" $owner }} 37 - <span class="select-none before:content-['\00B7']"></span> 38 - {{ template "repo/fragments/time" .Issue.Created }} 39 - </span> 40 - </div> 41 - 42 - {{ if .Issue.Body }} 43 - <article id="body" class="mt-8 prose dark:prose-invert"> 44 - {{ .Issue.Body | markdown }} 45 - </article> 46 - {{ end }} 11 + {{ define "repoContentLayout" }} 12 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 13 + <div class="col-span-1 md:col-span-8"> 14 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 15 + {{ block "repoContent" . }}{{ end }} 16 + </section> 17 + {{ block "repoAfter" . }}{{ end }} 18 + </div> 19 + <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 20 + {{ template "repo/fragments/labelPanel" 21 + (dict "RepoInfo" $.RepoInfo 22 + "Defs" $.LabelDefs 23 + "Subject" $.Issue.AtUri 24 + "State" $.Issue.Labels) }} 25 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 + </div> 27 + </div> 28 + {{ end }} 47 29 48 - <div class="flex items-center gap-2 mt-2"> 49 - {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 50 - {{ range $kind := .OrderedReactionKinds }} 51 - {{ 52 - template "repo/fragments/reaction" 53 - (dict 54 - "Kind" $kind 55 - "Count" (index $.Reactions $kind) 56 - "IsReacted" (index $.UserReacted $kind) 57 - "ThreadAt" $.Issue.AtUri) 58 - }} 59 - {{ end }} 60 - </div> 61 - </section> 30 + {{ define "repoContent" }} 31 + <section id="issue-{{ .Issue.IssueId }}"> 32 + {{ template "issueHeader" .Issue }} 33 + {{ template "issueInfo" . }} 34 + {{ if .Issue.Body }} 35 + <article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article> 36 + {{ end }} 37 + <div class="flex flex-wrap gap-2 items-stretch mt-4"> 38 + {{ template "issueReactions" . }} 39 + </div> 40 + </section> 62 41 {{ end }} 63 42 64 - {{ define "repoAfter" }} 65 - <section id="comments" class="my-2 mt-2 space-y-2 relative"> 66 - {{ range $index, $comment := .Comments }} 67 - <div 68 - id="comment-{{ .CommentId }}" 69 - class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 70 - {{ if gt $index 0 }} 71 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 72 - {{ end }} 73 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}} 74 - </div> 75 - {{ end }} 76 - </section> 43 + {{ define "issueHeader" }} 44 + <header class="pb-2"> 45 + <h1 class="text-2xl"> 46 + {{ .Title | description }} 47 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 48 + </h1> 49 + </header> 50 + {{ end }} 77 51 78 - {{ block "newComment" . }} {{ end }} 52 + {{ define "issueInfo" }} 53 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 + {{ $icon := "ban" }} 55 + {{ if eq .Issue.State "open" }} 56 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 57 + {{ $icon = "circle-dot" }} 58 + {{ end }} 59 + <div class="inline-flex items-center gap-2"> 60 + <div id="state" 61 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 62 + {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 63 + <span class="text-white">{{ .Issue.State }}</span> 64 + </div> 65 + <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 66 + opened by 67 + {{ template "user/fragments/picHandleLink" .Issue.Did }} 68 + <span class="select-none before:content-['\00B7']"></span> 69 + {{ if .Issue.Edited }} 70 + edited {{ template "repo/fragments/time" .Issue.Edited }} 71 + {{ else }} 72 + {{ template "repo/fragments/time" .Issue.Created }} 73 + {{ end }} 74 + </span> 79 75 76 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 77 + {{ template "issueActions" . }} 78 + {{ end }} 79 + </div> 80 + <div id="issue-actions-error" class="error"></div> 80 81 {{ end }} 81 82 82 - {{ define "newComment" }} 83 - {{ if .LoggedInUser }} 84 - <form 85 - id="comment-form" 86 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 87 - hx-on::after-request="if(event.detail.successful) this.reset()" 88 - > 89 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 90 - <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 91 - {{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }} 92 - </div> 93 - <textarea 94 - id="comment-textarea" 95 - name="body" 96 - class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 97 - placeholder="Add to the discussion. Markdown is supported." 98 - onkeyup="updateCommentForm()" 99 - ></textarea> 100 - <div id="issue-comment"></div> 101 - <div id="issue-action" class="error"></div> 102 - </div> 83 + {{ define "issueActions" }} 84 + {{ template "editIssue" . }} 85 + {{ template "deleteIssue" . }} 86 + {{ end }} 103 87 104 - <div class="flex gap-2 mt-2"> 105 - <button 106 - id="comment-button" 107 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 108 - type="submit" 109 - hx-disabled-elt="#comment-button" 110 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group" 111 - disabled 112 - > 113 - {{ i "message-square-plus" "w-4 h-4" }} 114 - comment 115 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 116 - </button> 88 + {{ define "editIssue" }} 89 + <a 90 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 91 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 92 + hx-swap="innerHTML" 93 + hx-target="#issue-{{.Issue.IssueId}}"> 94 + {{ i "pencil" "size-3" }} 95 + </a> 96 + {{ end }} 117 97 118 - {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }} 119 - {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 120 - {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 121 - {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }} 122 - <button 123 - id="close-button" 124 - type="button" 125 - class="btn flex items-center gap-2" 126 - hx-indicator="#close-spinner" 127 - hx-trigger="click" 128 - > 129 - {{ i "ban" "w-4 h-4" }} 130 - close 131 - <span id="close-spinner" class="group"> 132 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 133 - </span> 134 - </button> 135 - <div 136 - id="close-with-comment" 137 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 138 - hx-trigger="click from:#close-button" 139 - hx-disabled-elt="#close-with-comment" 140 - hx-target="#issue-comment" 141 - hx-indicator="#close-spinner" 142 - hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 143 - hx-swap="none" 144 - > 145 - </div> 146 - <div 147 - id="close-issue" 148 - hx-disabled-elt="#close-issue" 149 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 150 - hx-trigger="click from:#close-button" 151 - hx-target="#issue-action" 152 - hx-indicator="#close-spinner" 153 - hx-swap="none" 154 - > 155 - </div> 156 - <script> 157 - document.addEventListener('htmx:configRequest', function(evt) { 158 - if (evt.target.id === 'close-with-comment') { 159 - const commentText = document.getElementById('comment-textarea').value.trim(); 160 - if (commentText === '') { 161 - evt.detail.parameters = {}; 162 - evt.preventDefault(); 163 - } 164 - } 165 - }); 166 - </script> 167 - {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }} 168 - <button 169 - type="button" 170 - class="btn flex items-center gap-2" 171 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 172 - hx-indicator="#reopen-spinner" 173 - hx-swap="none" 174 - > 175 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 176 - reopen 177 - <span id="reopen-spinner" class="group"> 178 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 179 - </span> 180 - </button> 181 - {{ end }} 98 + {{ define "deleteIssue" }} 99 + <a 100 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 101 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 102 + hx-confirm="Are you sure you want to delete your issue?" 103 + hx-swap="none"> 104 + {{ i "trash-2" "size-3" }} 105 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 106 + </a> 107 + {{ end }} 182 108 183 - <script> 184 - function updateCommentForm() { 185 - const textarea = document.getElementById('comment-textarea'); 186 - const commentButton = document.getElementById('comment-button'); 187 - const closeButton = document.getElementById('close-button'); 109 + {{ define "issueReactions" }} 110 + <div class="flex items-center gap-2"> 111 + {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 + {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 114 + {{ 115 + template "repo/fragments/reaction" 116 + (dict 117 + "Kind" $kind 118 + "Count" $reactionData.Count 119 + "IsReacted" (index $.UserReacted $kind) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 122 + }} 123 + {{ end }} 124 + </div> 125 + {{ end }} 188 126 189 - if (textarea.value.trim() !== '') { 190 - commentButton.removeAttribute('disabled'); 191 - } else { 192 - commentButton.setAttribute('disabled', ''); 193 - } 194 127 195 - if (closeButton) { 196 - if (textarea.value.trim() !== '') { 197 - closeButton.innerHTML = ` 198 - {{ i "ban" "w-4 h-4" }} 199 - <span>close with comment</span> 200 - <span id="close-spinner" class="group"> 201 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 202 - </span>`; 203 - } else { 204 - closeButton.innerHTML = ` 205 - {{ i "ban" "w-4 h-4" }} 206 - <span>close</span> 207 - <span id="close-spinner" class="group"> 208 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 209 - </span>`; 210 - } 211 - } 212 - } 128 + {{ define "repoAfter" }} 129 + <div class="flex flex-col gap-4 mt-4"> 130 + {{ 131 + template "repo/issues/fragments/commentList" 132 + (dict 133 + "RepoInfo" $.RepoInfo 134 + "LoggedInUser" $.LoggedInUser 135 + "Issue" $.Issue 136 + "CommentList" $.Issue.CommentList) 137 + }} 213 138 214 - document.addEventListener('DOMContentLoaded', function() { 215 - updateCommentForm(); 216 - }); 217 - </script> 218 - </div> 219 - </form> 220 - {{ else }} 221 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 222 - <a href="/login" class="underline">login</a> to join the discussion 223 - </div> 224 - {{ end }} 139 + {{ template "repo/issues/fragments/newComment" . }} 140 + </div> 225 141 {{ end }}
+4 -49
appview/pages/templates/repo/issues/issues.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := "issues"}} 5 - {{ $url := printf "https://tangled.sh/%s/issues" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/issues" .RepoInfo.FullName }} 6 6 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 8 {{ end }} ··· 37 37 {{ end }} 38 38 39 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 - 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 - 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 69 - </span> 70 - 71 - <span class="before:content-['·']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 - 75 - <span class="before:content-['·']"> 76 - {{ $s := "s" }} 77 - {{ if eq .Metadata.CommentCount 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a> 81 - </span> 82 - </p> 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 83 42 </div> 84 - {{ end }} 85 - </div> 86 - 87 - {{ block "pagination" . }} {{ end }} 88 - 43 + {{ block "pagination" . }} {{ end }} 89 44 {{ end }} 90 45 91 46 {{ define "pagination" }}
+1 -33
appview/pages/templates/repo/issues/new.html
··· 1 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "repoContent" }} 4 - <form 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/new" 6 - class="mt-6 space-y-6" 7 - hx-swap="none" 8 - hx-indicator="#spinner" 9 - > 10 - <div class="flex flex-col gap-4"> 11 - <div> 12 - <label for="title">title</label> 13 - <input type="text" name="title" id="title" class="w-full" /> 14 - </div> 15 - <div> 16 - <label for="body">body</label> 17 - <textarea 18 - name="body" 19 - id="body" 20 - rows="6" 21 - class="w-full resize-y" 22 - placeholder="Describe your issue. Markdown is supported." 23 - ></textarea> 24 - </div> 25 - <div> 26 - <button type="submit" class="btn-create flex items-center gap-2"> 27 - {{ i "circle-plus" "w-4 h-4" }} 28 - create issue 29 - <span id="create-pull-spinner" class="group"> 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </span> 32 - </button> 33 - </div> 34 - </div> 35 - <div id="issues" class="error"></div> 36 - </form> 4 + {{ template "repo/issues/fragments/putIssue" . }} 37 5 {{ end }}
+1 -1
appview/pages/templates/repo/log.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := printf "commits &middot; %s" .RepoInfo.FullName }} 5 - {{ $url := printf "https://tangled.sh/%s/commits" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/commits" .RepoInfo.FullName }} 6 6 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 8 {{ end }}
+60
appview/pages/templates/repo/needsUpgrade.html
··· 1 + {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 + {{ define "extrameta" }} 3 + {{ template "repo/fragments/meta" . }} 4 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }} 5 + {{ end }} 6 + {{ define "repoContent" }} 7 + <main> 8 + <div class="relative w-full h-96 flex items-center justify-center"> 9 + <div class="w-full h-full grid grid-cols-1 md:grid-cols-2 gap-4 md:divide-x divide-gray-300 dark:divide-gray-600 text-gray-300 dark:text-gray-600"> 10 + <!-- mimic the repo view here, placeholders are LLM generated --> 11 + <div id="file-list" class="flex flex-col gap-2 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 12 + {{ $files := 13 + (list 14 + "src" 15 + "docs" 16 + "config" 17 + "lib" 18 + "index.html" 19 + "log.html" 20 + "needsUpgrade.html" 21 + "new.html" 22 + "tags.html" 23 + "tree.html") 24 + }} 25 + {{ range $files }} 26 + <span> 27 + {{ if (contains . ".") }} 28 + {{ i "file" "size-4 inline-flex" }} 29 + {{ else }} 30 + {{ i "folder" "size-4 inline-flex fill-current" }} 31 + {{ end }} 32 + 33 + {{ . }} 34 + </span> 35 + {{ end }} 36 + </div> 37 + <div id="commit-list" class="hidden md:flex md:flex-col gap-4 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 38 + {{ $commits := 39 + (list 40 + "Fix authentication bug in login flow" 41 + "Add new dashboard widgets for metrics" 42 + "Implement real-time notifications system") 43 + }} 44 + {{ range $commits }} 45 + <div class="flex flex-col"> 46 + <span>{{ . }}</span> 47 + <span class="text-xs">{{ . }}</span> 48 + </div> 49 + {{ end }} 50 + </div> 51 + </div> 52 + <div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur"> 53 + <div class="text-center"> 54 + {{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }} 55 + The knot hosting this repository needs an upgrade. This repository is currently unavailable. 56 + </div> 57 + </div> 58 + </div> 59 + </main> 60 + {{ end }}
+163 -61
appview/pages/templates/repo/new.html
··· 1 1 {{ define "title" }}new repo{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 4 + <div class="grid grid-cols-12"> 5 + <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 6 + <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Repositories contain a project's files and version history. All 9 + repositories are publicly accessible. 10 + </p> 11 + </div> 12 + {{ template "newRepoPanel" . }} 6 13 </div> 7 - <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 - <div class="space-y-2"> 10 - <label for="name" class="-mb-1 dark:text-white">Repository name</label> 11 - <input 12 - type="text" 13 - id="name" 14 - name="name" 15 - required 16 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 17 - /> 18 - <p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p> 14 + {{ end }} 19 15 20 - <label for="branch" class="dark:text-white">Default branch</label> 21 - <input 22 - type="text" 23 - id="branch" 24 - name="branch" 25 - value="main" 26 - required 27 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 28 - /> 16 + {{ define "newRepoPanel" }} 17 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 18 + {{ template "newRepoForm" . }} 19 + </div> 20 + {{ end }} 29 21 30 - <label for="description" class="dark:text-white">Description</label> 31 - <input 32 - type="text" 33 - id="description" 34 - name="description" 35 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 36 - /> 22 + {{ define "newRepoForm" }} 23 + <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 24 + {{ template "step-1" . }} 25 + {{ template "step-2" . }} 26 + 27 + <div class="mt-8 flex justify-end"> 28 + <button type="submit" class="btn-create flex items-center gap-2"> 29 + {{ i "book-plus" "w-4 h-4" }} 30 + create repo 31 + <span id="spinner" class="group"> 32 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </span> 34 + </button> 37 35 </div> 36 + <div id="repo" class="error mt-2"></div> 38 37 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 38 + </form> 39 + {{ end }} 40 + 41 + {{ define "step-1" }} 42 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 43 + <div class="absolute -left-3 -top-0"> 44 + {{ template "numberCircle" 1 }} 45 + </div> 46 + 47 + <!-- Content column --> 48 + <div class="flex-1 pb-12"> 49 + <h2 class="text-lg font-semibold dark:text-white">General</h2> 50 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div> 51 + 41 52 <div class="space-y-2"> 42 - <div class="flex flex-col"> 43 - {{ range .Knots }} 44 - <div class="flex items-center"> 45 - <input 46 - type="radio" 47 - name="domain" 48 - value="{{ . }}" 49 - class="mr-2" 50 - id="domain-{{ . }}" 51 - /> 52 - <span class="dark:text-white">{{ . }}</span> 53 - </div> 54 - {{ else }} 55 - <p class="dark:text-white">No knots available.</p> 56 - {{ end }} 57 - </div> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 58 55 </div> 59 - <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 60 - </fieldset> 56 + </div> 57 + </div> 58 + {{ end }} 61 59 62 - <div class="space-y-2"> 63 - <button type="submit" class="btn-create flex items-center gap-2"> 64 - {{ i "book-plus" "w-4 h-4" }} 65 - create repo 66 - <span id="spinner" class="group"> 67 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 - </span> 69 - </button> 70 - <div id="repo" class="error"></div> 60 + {{ define "step-2" }} 61 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 62 + <div class="absolute -left-3 -top-0"> 63 + {{ template "numberCircle" 2 }} 71 64 </div> 72 - </form> 73 - </div> 65 + 66 + <div class="flex-1"> 67 + <h2 class="text-lg font-semibold dark:text-white">Configuration</h2> 68 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div> 69 + 70 + <div class="space-y-2"> 71 + {{ template "defaultBranch" . }} 72 + {{ template "knot" . }} 73 + </div> 74 + </div> 75 + </div> 76 + {{ end }} 77 + 78 + {{ define "name" }} 79 + <!-- Repository Name with Owner --> 80 + <div> 81 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 82 + Repository name 83 + </label> 84 + <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full"> 85 + <div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700"> 86 + {{ template "user/fragments/picHandle" .LoggedInUser.Did }} 87 + </div> 88 + <input 89 + type="text" 90 + id="name" 91 + name="name" 92 + required 93 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2" 94 + placeholder="repository-name" 95 + /> 96 + </div> 97 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 98 + Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 99 + </p> 100 + </div> 101 + {{ end }} 102 + 103 + {{ define "description" }} 104 + <!-- Description --> 105 + <div> 106 + <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 107 + Description 108 + </label> 109 + <input 110 + type="text" 111 + id="description" 112 + name="description" 113 + class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 114 + placeholder="A brief description of your project..." 115 + /> 116 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 117 + Optional. A short description to help others understand what your project does. 118 + </p> 119 + </div> 120 + {{ end }} 121 + 122 + {{ define "defaultBranch" }} 123 + <!-- Default Branch --> 124 + <div> 125 + <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 126 + Default branch 127 + </label> 128 + <input 129 + type="text" 130 + id="branch" 131 + name="branch" 132 + value="main" 133 + required 134 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 135 + /> 136 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 137 + The primary branch where development happens. Common choices are "main" or "master". 138 + </p> 139 + </div> 140 + {{ end }} 141 + 142 + {{ define "knot" }} 143 + <!-- Knot Selection --> 144 + <div> 145 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 146 + Select a knot 147 + </label> 148 + <div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 149 + {{ range .Knots }} 150 + <div class="flex items-center"> 151 + <input 152 + type="radio" 153 + name="domain" 154 + value="{{ . }}" 155 + class="mr-2" 156 + id="domain-{{ . }}" 157 + required 158 + /> 159 + <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 + </div> 161 + {{ else }} 162 + <p class="dark:text-white">no knots available.</p> 163 + {{ end }} 164 + </div> 165 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 + A knot hosts repository data and handles Git operations. 167 + You can also <a href="/knots" class="underline">register your own knot</a>. 168 + </p> 169 + </div> 170 + {{ end }} 171 + 172 + {{ define "numberCircle" }} 173 + <div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 174 + {{.}} 175 + </div> 74 176 {{ end }}
+2 -2
appview/pages/templates/repo/pipelines/pipelines.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := "pipelines"}} 5 - {{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }} 6 6 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 7 7 {{ end }} 8 8 ··· 60 60 <span class="inline-flex gap-2 items-center"> 61 61 <span class="font-bold">{{ $target }}</span> 62 62 {{ i "arrow-left" "size-4" }} 63 - {{ .Trigger.PRSourceBranch }} 63 + {{ .Trigger.PRSourceBranch }} 64 64 <span class="text-sm font-mono"> 65 65 @ 66 66 <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
+1 -1
appview/pages/templates/repo/pipelines/workflow.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := "pipelines"}} 5 - {{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }} 6 6 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 7 7 {{ end }} 8 8
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 33 33 <span>comment</span> 34 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 35 </button> 36 + {{ if .BranchDeleteStatus }} 37 + <button 38 + hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 39 + hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 40 + hx-swap="none" 41 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 42 + {{ i "git-branch" "w-4 h-4" }} 43 + <span>delete branch</span> 44 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 + </button> 46 + {{ end }} 36 47 {{ if and $isPushAllowed $isOpen $isLastRound }} 37 48 {{ $disabled := "" }} 38 49 {{ if $isConflicted }}
+4 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 66 66 <div class="flex items-center gap-2 mt-2"> 67 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 68 {{ range $kind := . }} 69 + {{ $reactionData := index $.Reactions $kind }} 69 70 {{ 70 71 template "repo/fragments/reaction" 71 72 (dict 72 73 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 + "Count" $reactionData.Count 74 75 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 + "ThreadAt" $.Pull.PullAt 77 + "Users" $reactionData.Users) 76 78 }} 77 79 {{ end }} 78 80 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 6 + {{ resolve .LoggedInUser.Did }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 52 52 </div> 53 53 {{ end }} 54 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 55 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 56 56 </div> 57 57 </div> 58 58 </a>
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 1 - {{ define "repo/pulls/fragments/summarizedHeader" }} 1 + {{ define "repo/pulls/fragments/summarizedPullHeader" }} 2 2 {{ $pull := index . 0 }} 3 3 {{ $pipeline := index . 1 }} 4 4 {{ with $pull }}
+3 -16
appview/pages/templates/repo/pulls/interdiff.html
··· 5 5 6 6 {{ define "extrameta" }} 7 7 {{ $title := printf "interdiff of %d and %d &middot; %s &middot; pull #%d &middot; %s" .Round (sub .Round 1) .Pull.Title .Pull.PullId .RepoInfo.FullName }} 8 - {{ $url := printf "https://tangled.sh/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }} 9 - 8 + {{ $url := printf "https://tangled.org/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }} 9 + 10 10 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" (unescapeHtml $title) "Url" $url) }} 11 11 {{ end }} 12 12 ··· 26 26 </header> 27 27 </section> 28 28 29 - {{ end }} 30 - 31 - {{ define "topbarLayout" }} 32 - <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/topbar" . }} 34 - </header> 35 29 {{ end }} 36 30 37 31 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 39 33 {{ block "contentLayout" . }} 40 34 {{ block "content" . }}{{ end }} 41 35 {{ end }} ··· 52 46 {{ end }} 53 47 </div> 54 48 {{ end }} 55 - 56 - {{ define "footerLayout" }} 57 - <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/footer" . }} 59 - </footer> 60 - {{ end }} 61 - 62 49 63 50 {{ define "contentAfter" }} 64 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+3 -15
appview/pages/templates/repo/pulls/patch.html
··· 5 5 6 6 {{ define "extrameta" }} 7 7 {{ $title := printf "patch of %s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 8 - {{ $url := printf "https://tangled.sh/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }} 9 - 8 + {{ $url := printf "https://tangled.org/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }} 9 + 10 10 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 11 11 {{ end }} 12 12 ··· 34 34 </section> 35 35 {{ end }} 36 36 37 - {{ define "topbarLayout" }} 38 - <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/topbar" . }} 40 - </header> 41 - {{ end }} 42 - 43 37 {{ define "mainLayout" }} 44 - <div class="px-1 col-span-full flex flex-col gap-4"> 38 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 45 39 {{ block "contentLayout" . }} 46 40 {{ block "content" . }}{{ end }} 47 41 {{ end }} ··· 57 51 </div> 58 52 {{ end }} 59 53 </div> 60 - {{ end }} 61 - 62 - {{ define "footerLayout" }} 63 - <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/footer" . }} 65 - </footer> 66 54 {{ end }} 67 55 68 56 {{ define "contentAfter" }}
+48 -17
appview/pages/templates/repo/pulls/pull.html
··· 4 4 5 5 {{ define "extrameta" }} 6 6 {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 7 + {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 8 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11 12 + {{ define "repoContentLayout" }} 13 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 14 + <div class="col-span-1 md:col-span-8"> 15 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 16 + {{ block "repoContent" . }}{{ end }} 17 + </section> 18 + {{ block "repoAfter" . }}{{ end }} 19 + </div> 20 + <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 21 + {{ template "repo/fragments/labelPanel" 22 + (dict "RepoInfo" $.RepoInfo 23 + "Defs" $.LabelDefs 24 + "Subject" $.Pull.PullAt 25 + "State" $.Pull.Labels) }} 26 + {{ template "repo/fragments/participants" $.Pull.Participants }} 27 + </div> 28 + </div> 29 + {{ end }} 12 30 13 31 {{ define "repoContent" }} 14 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 57 {{ with $item }} 40 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 43 61 <!-- round number --> 44 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 64 </div> 47 65 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 67 <span class="gap-1 flex items-center"> 50 68 {{ $owner := resolve $.Pull.OwnerDid }} 51 69 {{ $re := "re" }} ··· 72 90 <span class="hidden md:inline">diff</span> 73 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 92 </a> 75 - {{ if not (eq .RoundNumber 0) }} 76 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 77 - hx-boost="true" 78 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 79 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 80 - <span class="hidden md:inline">interdiff</span> 81 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 - </a> 93 + {{ if ne $idx 0 }} 94 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 95 + hx-boost="true" 96 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 97 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 98 + <span class="hidden md:inline">interdiff</span> 99 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 + </a> 101 + {{ end }} 83 102 <span id="interdiff-error-{{.RoundNumber}}"></span> 84 - {{ end }} 85 103 </div> 86 104 </summary> 87 105 ··· 146 164 147 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 166 {{ range $cidx, $c := .Comments }} 149 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 150 168 {{ if gt $cidx 0 }} 151 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 170 {{ end }} ··· 169 187 {{ end }} 170 188 171 189 {{ if $.LoggedInUser }} 172 - {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 190 + {{ template "repo/pulls/fragments/pullActions" 191 + (dict 192 + "LoggedInUser" $.LoggedInUser 193 + "Pull" $.Pull 194 + "RepoInfo" $.RepoInfo 195 + "RoundNumber" .RoundNumber 196 + "MergeCheck" $.MergeCheck 197 + "ResubmitCheck" $.ResubmitCheck 198 + "BranchDeleteStatus" $.BranchDeleteStatus 199 + "Stack" $.Stack) }} 173 200 {{ else }} 174 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 175 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 176 - <a href="/login" class="underline">login</a> to join the discussion 201 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit"> 202 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 203 + sign up 204 + </a> 205 + <span class="text-gray-500 dark:text-gray-400">or</span> 206 + <a href="/login" class="underline">login</a> 207 + to add to the discussion 177 208 </div> 178 209 {{ end }} 179 210 </div>
+9 -2
appview/pages/templates/repo/pulls/pulls.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := "pulls"}} 5 - {{ $url := printf "https://tangled.sh/%s/pulls" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/pulls" .RepoInfo.FullName }} 6 6 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 8 {{ end }} ··· 108 108 <span class="before:content-['·']"></span> 109 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 110 {{ end }} 111 + 112 + {{ $state := .Labels }} 113 + {{ range $k, $d := $.LabelDefs }} 114 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 115 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 116 + {{ end }} 117 + {{ end }} 111 118 </div> 112 119 </div> 113 120 {{ if .StackId }} ··· 144 151 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 145 152 <div class="flex gap-2 items-center px-6"> 146 153 <div class="flex-grow min-w-0 w-full py-2"> 147 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 154 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 148 155 </div> 149 156 </div> 150 157 </a>
+165
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
··· 1 + {{ define "repo/settings/fragments/addLabelDefModal" }} 2 + <div class="grid grid-cols-2"> 3 + <input type="radio" name="tab" id="basic-tab" value="basic" class="hidden peer/basic" checked> 4 + <input type="radio" name="tab" id="kv-tab" value="kv" class="hidden peer/kv"> 5 + 6 + <!-- Labels as direct siblings --> 7 + {{ $base := "py-2 text-sm font-normal normal-case block hover:no-underline text-center cursor-pointer bg-gray-100 dark:bg-gray-800 shadow-inner border border-gray-200 dark:border-gray-700" }} 8 + <label for="basic-tab" class="{{$base}} peer-checked/basic:bg-white peer-checked/basic:dark:bg-gray-700 peer-checked/basic:shadow-sm rounded-l"> 9 + Basic Labels 10 + </label> 11 + <label for="kv-tab" class="{{$base}} peer-checked/kv:bg-white peer-checked/kv:dark:bg-gray-700 peer-checked/kv:shadow-sm rounded-r"> 12 + Key-value Labels 13 + </label> 14 + 15 + <!-- Basic Labels Content - direct sibling --> 16 + <div class="mt-4 hidden peer-checked/basic:block col-span-full"> 17 + {{ template "basicLabelDef" . }} 18 + </div> 19 + 20 + <!-- Key-value Labels Content - direct sibling --> 21 + <div class="mt-4 hidden peer-checked/kv:block col-span-full"> 22 + {{ template "kvLabelDef" . }} 23 + </div> 24 + 25 + <div id="add-label-error" class="text-red-500 dark:text-red-400 col-span-full"></div> 26 + </div> 27 + {{ end }} 28 + 29 + {{ define "basicLabelDef" }} 30 + <form 31 + hx-put="/{{ $.RepoInfo.FullName }}/settings/label" 32 + hx-indicator="#spinner" 33 + hx-swap="none" 34 + hx-on::after-request="if(event.detail.successful) this.reset()" 35 + class="flex flex-col space-y-4"> 36 + 37 + <p class="text-gray-500 dark:text-gray-400">These labels can have a name and a color.</p> 38 + 39 + {{ template "nameInput" . }} 40 + {{ template "scopeInput" . }} 41 + {{ template "colorInput" . }} 42 + 43 + <div class="flex gap-2 pt-2"> 44 + {{ template "cancelButton" . }} 45 + {{ template "submitButton" . }} 46 + </div> 47 + </form> 48 + {{ end }} 49 + 50 + {{ define "kvLabelDef" }} 51 + <form 52 + hx-put="/{{ $.RepoInfo.FullName }}/settings/label" 53 + hx-indicator="#spinner" 54 + hx-swap="none" 55 + hx-on::after-request="if(event.detail.successful) this.reset()" 56 + class="flex flex-col space-y-4"> 57 + 58 + <p class="text-gray-500 dark:text-gray-400"> 59 + These labels are more detailed, they can have a key and an associated 60 + value. You may define additional constraints on label values. 61 + </p> 62 + 63 + {{ template "nameInput" . }} 64 + {{ template "valueInput" . }} 65 + {{ template "multipleInput" . }} 66 + {{ template "scopeInput" . }} 67 + {{ template "colorInput" . }} 68 + 69 + <div class="flex gap-2 pt-2"> 70 + {{ template "cancelButton" . }} 71 + {{ template "submitButton" . }} 72 + </div> 73 + </form> 74 + {{ end }} 75 + 76 + {{ define "nameInput" }} 77 + <div class="w-full"> 78 + <label for="name">Name</label> 79 + <input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/> 80 + </div> 81 + {{ end }} 82 + 83 + {{ define "colorInput" }} 84 + <div class="w-full"> 85 + <label for="color">Color</label> 86 + <div class="grid grid-cols-4 grid-rows-2 place-items-center"> 87 + {{ $colors := list "#ef4444" "#3b82f6" "#10b981" "#f59e0b" "#8b5cf6" "#ec4899" "#06b6d4" "#64748b" }} 88 + {{ range $i, $color := $colors }} 89 + <label class="relative"> 90 + <input type="radio" name="color" value="{{ $color }}" class="sr-only peer" {{ if eq $i 0 }} checked {{ end }}> 91 + {{ template "repo/fragments/colorBall" (dict "color" $color "classes" "size-4 peer-checked:size-8 transition-all") }} 92 + </label> 93 + {{ end }} 94 + </div> 95 + </div> 96 + {{ end }} 97 + 98 + {{ define "scopeInput" }} 99 + <div class="w-full"> 100 + <label>Scope</label> 101 + <label class="font-normal normal-case flex items-center gap-2 p-0"> 102 + <input type="checkbox" id="issue-scope" name="scope" value="sh.tangled.repo.issue" checked /> 103 + Issues 104 + </label> 105 + <label class="font-normal normal-case flex items-center gap-2 p-0"> 106 + <input type="checkbox" id="pulls-scope" name="scope" value="sh.tangled.repo.pull" checked /> 107 + Pull Requests 108 + </label> 109 + </div> 110 + {{ end }} 111 + 112 + {{ define "valueInput" }} 113 + <div class="w-full"> 114 + <label for="valueType">Value Type</label> 115 + <select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 116 + <option value="string">String</option> 117 + <option value="integer">Integer</option> 118 + </select> 119 + </div> 120 + 121 + <div class="w-full"> 122 + <label for="enumValues">Permitted values</label> 123 + <input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/> 124 + <p class="text-sm text-gray-400 dark:text-gray-500 mt-1"> 125 + Enter comma-separated list of permitted values, or leave empty to allow any value. 126 + </p> 127 + </div> 128 + 129 + <div class="w-full"> 130 + <label for="valueFormat">String format</label> 131 + <select id="valueFormat" name="valueFormat" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 132 + <option value="any" selected>Any</option> 133 + <option value="did">DID</option> 134 + </select> 135 + </div> 136 + {{ end }} 137 + 138 + {{ define "multipleInput" }} 139 + <div class="w-full flex flex-wrap gap-2"> 140 + <input type="checkbox" id="multiple" name="multiple" value="true" /> 141 + <span>Allow multiple values</span> 142 + </div> 143 + {{ end }} 144 + 145 + {{ define "cancelButton" }} 146 + <button 147 + type="button" 148 + popovertarget="add-labeldef-modal" 149 + popovertargetaction="hide" 150 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 151 + > 152 + {{ i "x" "size-4" }} cancel 153 + </button> 154 + {{ end }} 155 + 156 + {{ define "submitButton" }} 157 + <button type="submit" class="btn-create w-1/2 flex items-center"> 158 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 159 + <span id="spinner" class="group"> 160 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 161 + </span> 162 + </button> 163 + {{ end }} 164 + 165 +
+32
appview/pages/templates/repo/settings/fragments/labelListing.html
··· 1 + {{ define "repo/settings/fragments/labelListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $label := index . 1 }} 4 + <div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 5 + {{ template "labels/fragments/labelDef" $label }} 6 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 7 + {{ if $label.ValueType.IsNull }} 8 + basic 9 + {{ else }} 10 + {{ $label.ValueType.Type }} type 11 + {{ end }} 12 + 13 + {{ if $label.ValueType.IsEnum }} 14 + <span class="before:content-['·'] before:select-none"></span> 15 + {{ join $label.ValueType.Enum ", " }} 16 + {{ end }} 17 + 18 + {{ if $label.ValueType.IsDidFormat }} 19 + <span class="before:content-['·'] before:select-none"></span> 20 + DID format 21 + {{ end }} 22 + 23 + {{ if $label.Multiple }} 24 + <span class="before:content-['·'] before:select-none"></span> 25 + multiple 26 + {{ end }} 27 + 28 + <span class="before:content-['·'] before:select-none"></span> 29 + {{ join $label.Scope ", " }} 30 + </div> 31 + </div> 32 + {{ end }}
+126
appview/pages/templates/repo/settings/general.html
··· 7 7 </div> 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 9 {{ template "branchSettings" . }} 10 + {{ template "defaultLabelSettings" . }} 11 + {{ template "customLabelSettings" . }} 10 12 {{ template "deleteRepo" . }} 11 13 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 12 14 </div> ··· 42 44 </div> 43 45 {{ end }} 44 46 47 + {{ define "defaultLabelSettings" }} 48 + <div class="flex flex-col gap-2"> 49 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 50 + <div class="col-span-1 md:col-span-2"> 51 + <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 52 + <p class="text-gray-500 dark:text-gray-400"> 53 + Manage your issues and pulls by creating labels to categorize them. Only 54 + repository owners may configure labels. You may choose to subscribe to 55 + default labels, or create entirely custom labels. 56 + <p> 57 + </div> 58 + <form class="col-span-1 md:col-span-1 md:justify-self-end"> 59 + {{ $title := "Unubscribe from all labels" }} 60 + {{ $icon := "x" }} 61 + {{ $text := "unsubscribe all" }} 62 + {{ $action := "unsubscribe" }} 63 + {{ if $.ShouldSubscribeAll }} 64 + {{ $title = "Subscribe to all labels" }} 65 + {{ $icon = "check-check" }} 66 + {{ $text = "subscribe all" }} 67 + {{ $action = "subscribe" }} 68 + {{ end }} 69 + {{ range .DefaultLabels }} 70 + <input type="hidden" name="label" value="{{ .AtUri.String }}"> 71 + {{ end }} 72 + <button 73 + type="submit" 74 + title="{{$title}}" 75 + class="btn flex items-center gap-2 group" 76 + hx-swap="none" 77 + hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}" 78 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 79 + {{ i $icon "size-4" }} 80 + {{ $text }} 81 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 + </button> 83 + </form> 84 + </div> 85 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 86 + {{ range .DefaultLabels }} 87 + <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4"> 88 + {{ template "repo/settings/fragments/labelListing" (list $ .) }} 89 + {{ $action := "subscribe" }} 90 + {{ $icon := "plus" }} 91 + {{ if mapContains $.SubscribedLabels .AtUri.String }} 92 + {{ $action = "unsubscribe" }} 93 + {{ $icon = "minus" }} 94 + {{ end }} 95 + <button 96 + class="btn gap-2 group" 97 + title="{{$action}} from label" 98 + {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }} 99 + hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}" 100 + hx-swap="none" 101 + hx-vals='{"label": "{{ .AtUri.String }}"}'> 102 + {{ i $icon "size-4" }} 103 + <span class="hidden md:inline">{{$action}}</span> 104 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </button> 106 + </div> 107 + {{ else }} 108 + <div class="flex items-center justify-center p-2 text-gray-500"> 109 + no labels added yet 110 + </div> 111 + {{ end }} 112 + </div> 113 + <div id="default-label-operation" class="error"></div> 114 + </div> 115 + {{ end }} 116 + 117 + {{ define "customLabelSettings" }} 118 + <div class="flex flex-col gap-2"> 119 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 120 + <div class="col-span-1 md:col-span-2"> 121 + <h2 class="text-sm pb-2 uppercase font-bold">Custom Labels</h2> 122 + </div> 123 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 124 + <button 125 + title="Add custom label" 126 + class="btn flex items-center gap-2" 127 + popovertarget="add-labeldef-modal" 128 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }} 129 + popovertargetaction="toggle"> 130 + {{ i "plus" "size-4" }} 131 + add label 132 + </button> 133 + <div 134 + id="add-labeldef-modal" 135 + popover 136 + class="bg-white w-full sm:w-[30rem] dark:bg-gray-800 p-6 max-h-dvh overflow-y-auto rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 137 + {{ template "repo/settings/fragments/addLabelDefModal" . }} 138 + </div> 139 + </div> 140 + </div> 141 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 142 + {{ range .Labels }} 143 + <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4"> 144 + {{ template "repo/settings/fragments/labelListing" (list $ .) }} 145 + {{ if $.RepoInfo.Roles.IsOwner }} 146 + <button 147 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 148 + title="Delete label" 149 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/label" 150 + hx-swap="none" 151 + hx-vals='{"label-id": "{{ .Id }}"}' 152 + hx-confirm="Are you sure you want to delete the label `{{ .Name }}`?" 153 + > 154 + {{ i "trash-2" "w-5 h-5" }} 155 + <span class="hidden md:inline">delete</span> 156 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 157 + </button> 158 + {{ end }} 159 + </div> 160 + {{ else }} 161 + <div class="flex items-center justify-center p-2 text-gray-500"> 162 + no labels added yet 163 + </div> 164 + {{ end }} 165 + </div> 166 + <div id="label-operation" class="error"></div> 167 + </div> 168 + {{ end }} 169 + 45 170 {{ define "deleteRepo" }} 46 171 {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 47 172 <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> ··· 68 193 </div> 69 194 {{ end }} 70 195 {{ end }} 196 +
+2 -2
appview/pages/templates/repo/settings/pipelines.html
··· 22 22 <p class="text-gray-500 dark:text-gray-400"> 23 23 Choose a spindle to execute your workflows on. Only repository owners 24 24 can configure spindles. Spindles can be selfhosted, 25 - <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 26 26 click to learn more. 27 27 </a> 28 28 </p> ··· 109 109 hx-swap="none" 110 110 class="flex flex-col gap-2" 111 111 > 112 - <p class="uppercase p-0">ADD SECRET</p> 112 + <p class="uppercase p-0 font-bold">ADD SECRET</p> 113 113 <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 114 <input 115 115 type="text"
+10 -10
appview/pages/templates/repo/tags.html
··· 4 4 5 5 {{ define "extrameta" }} 6 6 {{ $title := printf "tags &middot; %s" .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/tags" .RepoInfo.FullName }} 8 - 7 + {{ $url := printf "https://tangled.org/%s/tags" .RepoInfo.FullName }} 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11 ··· 26 26 27 27 <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 28 28 {{ if .Tag }} 29 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 29 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 30 30 class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 31 31 {{ slice .Tag.Target.String 0 8 }} 32 32 </a> ··· 48 48 </a> 49 49 <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 50 50 {{ if .Tag }} 51 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 51 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 52 52 class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 53 53 {{ i "git-commit-horizontal" "w-4 h-4" }} 54 54 {{ slice .Tag.Target.String 0 8 }} ··· 132 132 hx-target="this" 133 133 class="flex items-center gap-2 px-2"> 134 134 <div class="flex-grow"> 135 - <input type="file" 136 - name="artifact" 135 + <input type="file" 136 + name="artifact" 137 137 required 138 138 class="block py-2 px-0 w-full border-none 139 139 text-black dark:text-white ··· 148 148 </input> 149 149 </div> 150 150 <div class="flex justify-end"> 151 - <button 152 - type="submit" 153 - class="btn gap-2" 151 + <button 152 + type="submit" 153 + class="btn gap-2" 154 154 id="upload-btn-{{$unique}}" 155 155 title="Upload artifact"> 156 156 {{ i "upload" "w-4 h-4" }} 157 - <span class="hidden md:inline">upload</span> 157 + <span class="hidden md:inline">upload</span> 158 158 </button> 159 159 </div> 160 160 </form>
+10 -4
appview/pages/templates/repo/tree.html
··· 10 10 11 11 {{ template "repo/fragments/meta" . }} 12 12 {{ $title := printf "%s at %s &middot; %s" $path .Ref .RepoInfo.FullName }} 13 - {{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }} 13 + {{ $url := printf "https://tangled.org/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }} 14 14 15 15 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 16 16 {{ end }} ··· 25 25 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 26 26 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 27 27 {{ range .BreadCrumbs }} 28 - <a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 28 + <a href="{{ index . 1 }}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 29 29 {{ end }} 30 30 </div> 31 31 <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"> 32 32 {{ $stats := .TreeStats }} 33 33 34 - <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span> 34 + <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ pathEscape $.Ref }}">{{ $.Ref }}</a></span> 35 35 {{ if eq $stats.NumFolders 1 }} 36 36 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 37 37 <span>{{ $stats.NumFolders }} folder</span> ··· 55 55 {{ range .Files }} 56 56 <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 57 <div class="col-span-8 md:col-span-4"> 58 - {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 58 + {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.Ref) $.TreePath .Name }} 59 59 {{ $icon := "folder" }} 60 60 {{ $iconStyle := "size-4 fill-current" }} 61 61 ··· 88 88 </div> 89 89 </main> 90 90 {{end}} 91 + 92 + {{ define "repoAfter" }} 93 + {{- if or .HTMLReadme .Readme -}} 94 + {{ template "repo/fragments/readme" . }} 95 + {{- end -}} 96 + {{ end }}
+6 -1
appview/pages/templates/spindles/fragments/spindleListing.html
··· 30 30 {{ define "spindleRightSide" }} 31 31 <div id="right-side" class="flex gap-2"> 32 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 - {{ if .Verified }} 33 + 34 + {{ if .NeedsUpgrade }} 35 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> {{ i "shield-alert" "w-4 h-4" }} needs upgrade </span> 36 + {{ block "spindleRetryButton" . }} {{ end }} 37 + {{ else if .Verified }} 34 38 <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 35 39 {{ template "spindles/fragments/addMemberModal" . }} 36 40 {{ else }} 37 41 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 38 42 {{ block "spindleRetryButton" . }} {{ end }} 39 43 {{ end }} 44 + 40 45 {{ block "spindleDeleteButton" . }} {{ end }} 41 46 </div> 42 47 {{ end }}
+10 -9
appview/pages/templates/spindles/index.html
··· 1 1 {{ define "title" }}spindles{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 + <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">docs</a> 9 + </span> 6 10 </div> 7 11 8 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 15 19 {{ end }} 16 20 17 21 {{ define "about" }} 18 - <section class="rounded flex flex-col gap-2"> 19 - <p class="dark:text-gray-300"> 20 - Spindles are small CI runners. 21 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 22 - Checkout the documentation if you're interested in self-hosting. 23 - </a> 22 + <section class="rounded flex items-center gap-2"> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Spindles are small CI runners. 24 25 </p> 25 - </section> 26 + </section> 26 27 {{ end }} 27 28 28 29 {{ define "list" }}
+1 -1
appview/pages/templates/strings/dashboard.html
··· 3 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 5 <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 6 + <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 8 {{ end }} 9 9
+2 -6
appview/pages/templates/strings/put.html
··· 1 1 {{ define "title" }}publish a new string{{ end }} 2 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 3 {{ define "content" }} 8 4 <div class="px-6 py-2 mb-4"> 9 5 {{ if eq .Action "new" }} 10 - <p class="text-xl font-bold dark:text-white">Create a new string</p> 11 - <p class="">Store and share code snippets with ease.</p> 6 + <p class="text-xl font-bold dark:text-white mb-1">Create a new string</p> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p> 12 8 {{ else }} 13 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 14 10 {{ end }}
+4 -7
appview/pages/templates/strings/string.html
··· 4 4 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 5 <meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" /> 6 6 <meta property="og:type" content="object" /> 7 - <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 7 + <meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 9 {{ end }} 10 10 11 - {{ define "topbar" }} 12 - {{ template "layouts/topbar" $ }} 13 - {{ end }} 14 - 15 11 {{ define "content" }} 16 12 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 13 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> ··· 27 23 hx-boost="true" 28 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 25 {{ i "pencil" "size-4" }} 30 - <span class="hidden md:inline">edit</span> 26 + <span class="hidden md:inline">edit</span> 31 27 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 28 </a> 33 29 <button ··· 38 34 hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 39 35 > 40 36 {{ i "trash-2" "size-4" }} 41 - <span class="hidden md:inline">delete</span> 37 + <span class="hidden md:inline">delete</span> 42 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 39 </button> 44 40 </div> ··· 84 80 <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 85 81 {{ end }} 86 82 </div> 83 + {{ template "fragments/multiline-select" }} 87 84 </section> 88 85 {{ end }}
+5 -11
appview/pages/templates/strings/timeline.html
··· 1 1 {{ define "title" }} all strings {{ end }} 2 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 3 {{ define "content" }} 8 4 {{ block "timeline" $ }}{{ end }} 9 5 {{ end }} ··· 30 26 {{ end }} 31 27 32 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 33 30 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 34 - <div class="font-medium dark:text-white flex gap-2 items-center"> 35 - <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 31 + <div class="font-medium dark:text-white flex flex-wrap gap-1 items-center"> 32 + <a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a> 33 + <span class="select-none">/</span> 34 + <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a> 36 35 </div> 37 36 {{ with .Description }} 38 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 46 45 47 46 {{ define "stringCardInfo" }} 48 47 {{ $stat := .Stats }} 49 - {{ $resolved := resolve .Did.String }} 50 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 51 - <a href="/strings/{{ $resolved }}" class="flex items-center"> 52 - {{ template "user/fragments/picHandle" $resolved }} 53 - </a> 54 - <span class="select-none [&:before]:content-['·']"></span> 55 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 56 50 <span class="select-none [&:before]:content-['·']"></span> 57 51 {{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 1 + {{ define "timeline/fragments/goodfirstissues" }} 2 + {{ if .GfiLabel }} 3 + <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 + <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 + <div class="flex-1 flex flex-col gap-2"> 6 + <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 + <p> 8 + Make your first contribution to an open-source project this October. 9 + <em>good-first-issue</em> helps new contributors find easy ways to 10 + start contributing to open-source projects. 11 + </p> 12 + <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 13 + Browse issues {{ i "arrow-right" "size-4" }} 14 + </span> 15 + </div> 16 + <div class="hidden md:block relative px-16 scale-150"> 17 + <div class="relative opacity-60"> 18 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 19 + </div> 20 + <div class="relative -mt-4 ml-2 opacity-80"> 21 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 22 + </div> 23 + <div class="relative -mt-4 ml-4"> 24 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 25 + </div> 26 + </div> 27 + </div> 28 + </a> 29 + {{ end }} 30 + {{ end }}
+33
appview/pages/templates/timeline/fragments/hero.html
··· 1 + {{ define "timeline/fragments/hero" }} 2 + <div class="mx-auto max-w-[100rem] flex flex-col text-black dark:text-white px-6 py-4 gap-6 items-center md:flex-row"> 3 + <div class="flex flex-col gap-6"> 4 + <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 + 6 + <p class="text-lg"> 7 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 8 + </p> 9 + <p class="text-lg"> 10 + we envision a place where developers have complete ownership of their 11 + code, open source communities can freely self-govern and most 12 + importantly, coding can be social and fun again. 13 + </p> 14 + 15 + <div class="flex gap-6 items-center"> 16 + <a href="/signup" class="no-underline hover:no-underline "> 17 + <button class="btn-create flex gap-2 px-4 items-center"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </button> 20 + </a> 21 + </div> 22 + </div> 23 + 24 + <figure class="w-full hidden md:block md:w-auto"> 25 + <a href="https://tangled.org/@tangled.org/core" class="block"> 26 + <img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" /> 27 + </a> 28 + <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> 29 + Monorepo for Tangled, built in the open with the community. 30 + </figcaption> 31 + </figure> 32 + </div> 33 + {{ end }}
+104
appview/pages/templates/timeline/fragments/timeline.html
··· 1 + {{ define "timeline/fragments/timeline" }} 2 + <div class="py-4"> 3 + <div class="px-6 pb-4"> 4 + <p class="text-xl font-bold dark:text-white">Timeline</p> 5 + </div> 6 + 7 + <div class="flex flex-col gap-4"> 8 + {{ range $i, $e := .Timeline }} 9 + <div class="relative"> 10 + {{ if ne $i 0 }} 11 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 12 + {{ end }} 13 + {{ with $e }} 14 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 15 + {{ if .Repo }} 16 + {{ template "timeline/fragments/repoEvent" (list $ .) }} 17 + {{ else if .Star }} 18 + {{ template "timeline/fragments/starEvent" (list $ .) }} 19 + {{ else if .Follow }} 20 + {{ template "timeline/fragments/followEvent" (list $ .) }} 21 + {{ end }} 22 + </div> 23 + {{ end }} 24 + </div> 25 + {{ end }} 26 + </div> 27 + </div> 28 + {{ end }} 29 + 30 + {{ define "timeline/fragments/repoEvent" }} 31 + {{ $root := index . 0 }} 32 + {{ $event := index . 1 }} 33 + {{ $repo := $event.Repo }} 34 + {{ $source := $event.Source }} 35 + {{ $userHandle := resolve $repo.Did }} 36 + <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"> 37 + {{ template "user/fragments/picHandleLink" $repo.Did }} 38 + {{ with $source }} 39 + {{ $sourceDid := resolve .Did }} 40 + forked 41 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 42 + {{ $sourceDid }}/{{ .Name }} 43 + </a> 44 + to 45 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 46 + {{ else }} 47 + created 48 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 49 + {{ $repo.Name }} 50 + </a> 51 + {{ end }} 52 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 53 + </div> 54 + {{ with $repo }} 55 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 56 + {{ end }} 57 + {{ end }} 58 + 59 + {{ define "timeline/fragments/starEvent" }} 60 + {{ $root := index . 0 }} 61 + {{ $event := index . 1 }} 62 + {{ $star := $event.Star }} 63 + {{ with $star }} 64 + {{ $starrerHandle := resolve .StarredByDid }} 65 + {{ $repoOwnerHandle := resolve .Repo.Did }} 66 + <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"> 67 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 68 + starred 69 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 70 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 71 + </a> 72 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 73 + </div> 74 + {{ with .Repo }} 75 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 76 + {{ end }} 77 + {{ end }} 78 + {{ end }} 79 + 80 + {{ define "timeline/fragments/followEvent" }} 81 + {{ $root := index . 0 }} 82 + {{ $event := index . 1 }} 83 + {{ $follow := $event.Follow }} 84 + {{ $profile := $event.Profile }} 85 + {{ $followStats := $event.FollowStats }} 86 + {{ $followStatus := $event.FollowStatus }} 87 + 88 + {{ $userHandle := resolve $follow.UserDid }} 89 + {{ $subjectHandle := resolve $follow.SubjectDid }} 90 + <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"> 91 + {{ template "user/fragments/picHandleLink" $userHandle }} 92 + followed 93 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 94 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 95 + </div> 96 + {{ template "user/fragments/followCard" 97 + (dict 98 + "LoggedInUser" $root.LoggedInUser 99 + "UserDid" $follow.SubjectDid 100 + "Profile" $profile 101 + "FollowStatus" $followStatus 102 + "FollowersCount" $followStats.Followers 103 + "FollowingCount" $followStats.Following) }} 104 + {{ end }}
+25
appview/pages/templates/timeline/fragments/trending.html
··· 1 + {{ define "timeline/fragments/trending" }} 2 + <div class="w-full md:mx-0 py-4"> 3 + <div class="px-6 pb-4"> 4 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 5 + Trending 6 + {{ i "trending-up" "size-4 flex-shrink-0" }} 7 + </h3> 8 + </div> 9 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 10 + {{ range $index, $repo := .Repos }} 11 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 12 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 13 + </div> 14 + {{ else }} 15 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 16 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 17 + No trending repositories this week 18 + </div> 19 + </div> 20 + {{ end }} 21 + </div> 22 + </div> 23 + {{ end }} 24 + 25 +
+90
appview/pages/templates/timeline/home.html
··· 1 + {{ define "title" }}tangled &middot; tightly-knit social coding{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline · tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.org" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="flex flex-col gap-4"> 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 16 + {{ template "timeline/fragments/trending" . }} 17 + {{ template "timeline/fragments/timeline" . }} 18 + <div class="flex justify-end"> 19 + <a href="/timeline" class="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400"> 20 + view more 21 + {{ i "arrow-right" "size-4" }} 22 + </a> 23 + </div> 24 + </div> 25 + {{ end }} 26 + 27 + 28 + {{ define "feature" }} 29 + {{ $info := index . 0 }} 30 + {{ $bullets := index . 1 }} 31 + <div class="flex flex-col items-center gap-6 md:flex-row md:items-top"> 32 + <div class="flex-1"> 33 + <h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2> 34 + <ul class="leading-normal"> 35 + {{ range $bullets }} 36 + <li><p>{{ escapeHtml . }}</p></li> 37 + {{ end }} 38 + </ul> 39 + </div> 40 + <div class="flex-shrink-0 w-96 md:w-1/3"> 41 + <a href="{{ $info.image }}"> 42 + <img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" /> 43 + </a> 44 + </div> 45 + </div> 46 + {{ end }} 47 + 48 + {{ define "features" }} 49 + <div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm"> 50 + {{ template "feature" (list 51 + (dict 52 + "title" "lightweight git repo hosting" 53 + "image" "https://assets.tangled.network/what-is-tangled-repo.png" 54 + "alt" "A repository hosted on Tangled" 55 + ) 56 + (list 57 + "Host your repositories on your own infrastructure using <em>knots</em>&mdash;tiny, headless servers that facilitate git operations." 58 + "Add friends to your knot or invite collaborators to your repository." 59 + "Guarded by fine-grained role-based access control." 60 + "Use SSH to push and pull." 61 + ) 62 + ) }} 63 + 64 + {{ template "feature" (list 65 + (dict 66 + "title" "improved pull request model" 67 + "image" "https://assets.tangled.network/pulls.png" 68 + "alt" "Round-based pull requests." 69 + ) 70 + (list 71 + "An intuitive and effective round-based pull request flow, with inter-diffing between rounds." 72 + "Stacked pull requests using Jujutsu's change IDs." 73 + "Paste a <code>git diff</code> or <code>git format-patch</code> for quick drive-by changes." 74 + ) 75 + ) }} 76 + 77 + {{ template "feature" (list 78 + (dict 79 + "title" "run pipelines using spindles" 80 + "image" "https://assets.tangled.network/pipelines.png" 81 + "alt" "CI pipeline running on spindle" 82 + ) 83 + (list 84 + "Run pipelines on your own infrastructure using <em>spindles</em>&mdash;lightweight CI runners." 85 + "Natively supports Nix for package management." 86 + "Easily extended to support different execution backends." 87 + ) 88 + ) }} 89 + </div> 90 + {{ end }}
+8 -172
appview/pages/templates/timeline/timeline.html
··· 3 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="timeline · tangled" /> 5 5 <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh" /> 6 + <meta property="og:url" content="https://tangled.org" /> 7 7 <meta property="og:description" content="tightly-knit social coding" /> 8 8 {{ end }} 9 9 10 10 {{ define "content" }} 11 - {{ if .LoggedInUser }} 12 - {{ else }} 13 - {{ block "hero" $ }}{{ end }} 14 - {{ end }} 15 - 16 - {{ block "trending" $ }}{{ end }} 17 - {{ block "timeline" $ }}{{ end }} 18 - {{ end }} 19 - 20 - {{ define "hero" }} 21 - <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 22 - <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 23 - 24 - <p class="text-lg"> 25 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 26 - </p> 27 - <p class="text-lg"> 28 - we envision a place where developers have complete ownership of their 29 - code, open source communities can freely self-govern and most 30 - importantly, coding can be social and fun again. 31 - </p> 32 - 33 - <div class="flex gap-6 items-center"> 34 - <a href="/signup" class="no-underline hover:no-underline "> 35 - <button class="btn-create flex gap-2 px-4 items-center"> 36 - join now {{ i "arrow-right" "size-4" }} 37 - </button> 38 - </a> 39 - </div> 40 - </div> 41 - {{ end }} 42 - 43 - {{ define "trending" }} 44 - <div class="w-full md:mx-0 py-4"> 45 - <div class="px-6 pb-4"> 46 - <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 47 - Trending 48 - {{ i "trending-up" "size-4 flex-shrink-0" }} 49 - </h3> 50 - </div> 51 - <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 52 - {{ range $index, $repo := .Repos }} 53 - <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 54 - {{ template "user/fragments/repoCard" (list $ $repo true) }} 55 - </div> 56 - {{ else }} 57 - <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 - <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 59 - No trending repositories this week 60 - </div> 61 - </div> 62 - {{ end }} 63 - </div> 64 - </div> 65 - {{ end }} 66 - 67 - {{ define "timeline" }} 68 - <div class="py-4"> 69 - <div class="px-6 pb-4"> 70 - <p class="text-xl font-bold dark:text-white">Timeline</p> 71 - </div> 72 - 73 - <div class="flex flex-col gap-4"> 74 - {{ range $i, $e := .Timeline }} 75 - <div class="relative"> 76 - {{ if ne $i 0 }} 77 - <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 78 - {{ end }} 79 - {{ with $e }} 80 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 81 - {{ if .Repo }} 82 - {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 83 - {{ else if .Star }} 84 - {{ block "starEvent" (list $ .Star) }} {{ end }} 85 - {{ else if .Follow }} 86 - {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 87 - {{ end }} 88 - </div> 89 - {{ end }} 90 - </div> 91 - {{ end }} 92 - </div> 93 - </div> 94 - {{ end }} 95 - 96 - {{ define "repoEvent" }} 97 - {{ $root := index . 0 }} 98 - {{ $repo := index . 1 }} 99 - {{ $source := index . 2 }} 100 - {{ $userHandle := resolve $repo.Did }} 101 - <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"> 102 - {{ template "user/fragments/picHandleLink" $repo.Did }} 103 - {{ with $source }} 104 - {{ $sourceDid := resolve .Did }} 105 - forked 106 - <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 107 - {{ $sourceDid }}/{{ .Name }} 108 - </a> 109 - to 110 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 111 - {{ else }} 112 - created 113 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 114 - {{ $repo.Name }} 115 - </a> 116 - {{ end }} 117 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 118 - </div> 119 - {{ with $repo }} 120 - {{ template "user/fragments/repoCard" (list $root . true) }} 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "starEvent" }} 125 - {{ $root := index . 0 }} 126 - {{ $star := index . 1 }} 127 - {{ with $star }} 128 - {{ $starrerHandle := resolve .StarredByDid }} 129 - {{ $repoOwnerHandle := resolve .Repo.Did }} 130 - <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"> 131 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 132 - starred 133 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 134 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 135 - </a> 136 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 137 - </div> 138 - {{ with .Repo }} 139 - {{ template "user/fragments/repoCard" (list $root . true) }} 140 - {{ end }} 141 - {{ end }} 142 - {{ end }} 143 - 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ end }} 144 15 145 - {{ define "followEvent" }} 146 - {{ $root := index . 0 }} 147 - {{ $follow := index . 1 }} 148 - {{ $profile := index . 2 }} 149 - {{ $stat := index . 3 }} 150 - 151 - {{ $userHandle := resolve $follow.UserDid }} 152 - {{ $subjectHandle := resolve $follow.SubjectDid }} 153 - <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"> 154 - {{ template "user/fragments/picHandleLink" $userHandle }} 155 - followed 156 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 157 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 158 - </div> 159 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 160 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 161 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 162 - </div> 163 - 164 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 165 - <a href="/{{ $subjectHandle }}"> 166 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 167 - </a> 168 - {{ with $profile }} 169 - {{ with .Description }} 170 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 171 - {{ end }} 172 - {{ end }} 173 - {{ with $stat }} 174 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 175 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 - <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 177 - <span class="select-none after:content-['·']"></span> 178 - <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 179 - </div> 180 - {{ end }} 181 - </div> 182 - </div> 16 + {{ template "timeline/fragments/goodfirstissues" . }} 17 + {{ template "timeline/fragments/trending" . }} 18 + {{ template "timeline/fragments/timeline" . }} 183 19 {{ end }}
+4 -5
appview/pages/templates/user/completeSignup.html
··· 13 13 /> 14 14 <meta 15 15 property="og:url" 16 - content="https://tangled.sh/complete-signup" 16 + content="https://tangled.org/complete-signup" 17 17 /> 18 18 <meta 19 19 property="og:description" 20 20 content="complete your signup for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 23 24 <link 24 25 rel="stylesheet" 25 26 href="/static/tw.css?{{ cssContentHash }}" ··· 29 30 </head> 30 31 <body class="flex items-center justify-center min-h-screen"> 31 32 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 35 - tangled 33 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 34 + {{ template "fragments/logotype" }} 36 35 </h1> 37 36 <h2 class="text-center text-xl italic dark:text-white"> 38 37 tightly-knit social coding.
+12 -17
appview/pages/templates/user/followers.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · followers {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "followers" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "followers" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "followers" }} 22 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 23 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 24 12 {{ range .Followers }} 25 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 26 21 {{ else }} 27 22 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 28 23 {{ end }}
+12 -17
appview/pages/templates/user/following.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · following {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "following" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "following" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "following" }} 22 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 23 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 24 12 {{ range .Following }} 25 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 26 21 {{ else }} 27 22 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 28 23 {{ end }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 13 13 <label class="m-0 p-0" for="description">bio</label> 14 14 <textarea 15 15 type="text" 16 - class="py-1 px-1 w-full" 16 + class="p-2 w-full" 17 17 name="description" 18 18 rows="3" 19 19 placeholder="write a bio">{{ $description }}</textarea>
+6 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 - class="btn mt-2 w-full flex gap-2 items-center group" 3 + class="btn w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 - {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }} 15 + {{ if eq .FollowStatus.String "IsNotFollowing" }} 16 + {{ i "user-round-plus" "w-4 h-4" }} follow 17 + {{ else }} 18 + {{ i "user-round-minus" "w-4 h-4" }} unfollow 19 + {{ end }} 16 20 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 17 21 </button> 18 22 {{ end }}
+20 -17
appview/pages/templates/user/fragments/followCard.html
··· 1 1 {{ define "user/fragments/followCard" }} 2 2 {{ $userIdent := resolve .UserDid }} 3 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 - <a href="/{{ $userIdent }}"> 11 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 - </a> 13 - <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 - <span class="select-none after:content-['·']"></span> 18 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 10 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 + <a href="/{{ $userIdent }}"> 12 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 + </a> 14 + {{ with .Profile }} 15 + <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 16 + {{ end }} 17 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 19 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 20 + <span class="select-none after:content-['·']"></span> 21 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 22 + </div> 19 23 </div> 20 - </div> 21 - 22 - {{ if ne .FollowStatus.String "IsSelf" }} 23 - <div class="max-w-24"> 24 + {{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }} 25 + <div class="w-full md:w-auto md:max-w-24 order-last md:order-none"> 24 26 {{ template "user/fragments/follow" . }} 25 27 </div> 26 - {{ end }} 28 + {{ end }} 29 + </div> 27 30 </div> 28 31 </div> 29 - {{ end }} 32 + {{ end }}
+3 -3
appview/pages/templates/user/fragments/picHandle.html
··· 1 1 {{ define "user/fragments/picHandle" }} 2 2 <img 3 3 src="{{ tinyAvatar . }}" 4 - alt="{{ . }}" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 4 + alt="" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 6 /> 7 - {{ . | truncateAt30 }} 7 + {{ . | resolve | truncateAt30 }} 8 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 5 4 </a> 6 5 {{ end }}
+2 -4
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 2 {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 4 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 5 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 6 5 <div class="w-3/4 aspect-square relative"> ··· 85 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 86 85 </div> 87 86 </div> 88 - </div> 89 87 {{ end }} 90 88 91 89 {{ define "followerFollowing" }} ··· 94 92 {{ with $root }} 95 93 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 94 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 98 96 <span class="select-none after:content-['·']"></span> 99 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 100 98 </div> 101 99 {{ end }} 102 100 {{ end }}
+27 -14
appview/pages/templates/user/fragments/repoCard.html
··· 2 2 {{ $root := index . 0 }} 3 3 {{ $repo := index . 1 }} 4 4 {{ $fullName := index . 2 }} 5 + {{ $starButton := false }} 6 + {{ $starData := dict }} 7 + {{ if gt (len .) 3 }} 8 + {{ $starButton = index . 3 }} 9 + {{ if gt (len .) 4 }} 10 + {{ $starData = index . 4 }} 11 + {{ end }} 12 + {{ end }} 5 13 6 14 {{ with $repo }} 7 15 <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32"> 8 - <div class="font-medium dark:text-white flex items-center"> 9 - {{ if .Source }} 10 - {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 11 - {{ else }} 12 - {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 16 + <div class="font-medium dark:text-white flex items-center justify-between"> 17 + <div class="flex items-center min-w-0 flex-1 mr-2"> 18 + {{ if .Source }} 19 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 20 + {{ else }} 21 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 22 + {{ end }} 23 + {{ $repoOwner := resolve .Did }} 24 + {{- if $fullName -}} 25 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a> 26 + {{- else -}} 27 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a> 28 + {{- end -}} 29 + </div> 30 + {{ if and $starButton $root.LoggedInUser }} 31 + <div class="shrink-0"> 32 + {{ template "repo/fragments/repoStar" $starData }} 33 + </div> 13 34 {{ end }} 14 - 15 - {{ $repoOwner := resolve .Did }} 16 - {{- if $fullName -}} 17 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 18 - {{- else -}} 19 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 20 - {{- end -}} 21 35 </div> 22 36 {{ with .Description }} 23 37 <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> ··· 36 50 <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 37 51 {{ with .Language }} 38 52 <div class="flex gap-2 items-center text-sm"> 39 - <div class="size-2 rounded-full" 40 - style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div> 53 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 41 54 <span>{{ . }}</span> 42 55 </div> 43 56 {{ end }}
+5 -4
appview/pages/templates/user/login.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <meta property="og:title" content="login · tangled" /> 8 - <meta property="og:url" content="https://tangled.sh/login" /> 8 + <meta property="og:url" content="https://tangled.org/login" /> 9 9 <meta property="og:description" content="login to for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>login &middot; tangled</title> 13 14 </head> 14 15 <body class="flex items-center justify-center min-h-screen"> 15 16 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 17 - tangled 17 + <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 18 + {{ template "fragments/logotype" }} 18 19 </h1> 19 20 <h2 class="text-center text-xl italic dark:text-white"> 20 21 tightly-knit social coding. ··· 36 37 placeholder="akshay.tngl.sh" 37 38 /> 38 39 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 40 + Use your <a href="https://atproto.com">AT Protocol</a> 40 41 handle to log in. If you're unsure, this is likely 41 42 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 43 </span>
+269
appview/pages/templates/user/overview.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 5 + <div class="grid grid-cols-1 gap-4"> 6 + {{ block "ownRepos" . }}{{ end }} 7 + {{ block "collaboratingRepos" . }}{{ end }} 8 + </div> 9 + </div> 10 + <div class="md:col-span-4 order-3 md:order-3"> 11 + {{ block "profileTimeline" . }}{{ end }} 12 + </div> 13 + {{ end }} 14 + 15 + {{ define "profileTimeline" }} 16 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p> 17 + <div class="flex flex-col gap-4 relative"> 18 + {{ if .ProfileTimeline.IsEmpty }} 19 + <p class="dark:text-white">This user does not have any activity yet.</p> 20 + {{ end }} 21 + 22 + {{ with .ProfileTimeline }} 23 + {{ range $idx, $byMonth := .ByMonth }} 24 + {{ with $byMonth }} 25 + {{ if not .IsEmpty }} 26 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm py-4 px-6"> 27 + <p class="text-sm font-mono mb-2 text-gray-500 dark:text-gray-400"> 28 + {{ if eq $idx 0 }} 29 + this month 30 + {{ else }} 31 + {{$idx}} month{{if ne $idx 1}}s{{end}} ago 32 + {{ end }} 33 + </p> 34 + 35 + <div class="flex flex-col gap-1"> 36 + {{ block "repoEvents" .RepoEvents }} {{ end }} 37 + {{ block "issueEvents" .IssueEvents }} {{ end }} 38 + {{ block "pullEvents" .PullEvents }} {{ end }} 39 + </div> 40 + </div> 41 + {{ end }} 42 + {{ end }} 43 + {{ end }} 44 + {{ end }} 45 + </div> 46 + {{ end }} 47 + 48 + {{ define "repoEvents" }} 49 + {{ if gt (len .) 0 }} 50 + <details> 51 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 52 + <div class="flex flex-wrap items-center gap-2"> 53 + {{ i "book-plus" "w-4 h-4" }} 54 + created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 55 + </div> 56 + </summary> 57 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 58 + {{ range . }} 59 + <div class="flex flex-wrap items-center justify-between gap-2"> 60 + <span class="flex items-center gap-2"> 61 + <span class="text-gray-500 dark:text-gray-400"> 62 + {{ if .Source }} 63 + {{ i "git-fork" "w-4 h-4" }} 64 + {{ else }} 65 + {{ i "book-plus" "w-4 h-4" }} 66 + {{ end }} 67 + </span> 68 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 69 + {{- .Repo.Name -}} 70 + </a> 71 + </span> 72 + 73 + {{ with .Repo.RepoStats }} 74 + {{ with .Language }} 75 + <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{end }} 80 + {{end }} 81 + </div> 82 + {{ end }} 83 + </div> 84 + </details> 85 + {{ end }} 86 + {{ end }} 87 + 88 + {{ define "issueEvents" }} 89 + {{ $items := .Items }} 90 + {{ $stats := .Stats }} 91 + 92 + {{ if gt (len $items) 0 }} 93 + <details> 94 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 95 + <div class="flex flex-wrap items-center gap-2"> 96 + {{ i "circle-dot" "w-4 h-4" }} 97 + 98 + <div> 99 + created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 100 + </div> 101 + 102 + {{ if gt $stats.Open 0 }} 103 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 104 + {{$stats.Open}} open 105 + </span> 106 + {{ end }} 107 + 108 + {{ if gt $stats.Closed 0 }} 109 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 110 + {{$stats.Closed}} closed 111 + </span> 112 + {{ end }} 113 + 114 + </div> 115 + </summary> 116 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 117 + {{ range $items }} 118 + {{ $repoOwner := resolve .Repo.Did }} 119 + {{ $repoName := .Repo.Name }} 120 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 121 + 122 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 123 + {{ if .Open }} 124 + <span class="text-green-600 dark:text-green-500"> 125 + {{ i "circle-dot" "w-4 h-4" }} 126 + </span> 127 + {{ else }} 128 + <span class="text-gray-500 dark:text-gray-400"> 129 + {{ i "ban" "w-4 h-4" }} 130 + </span> 131 + {{ end }} 132 + <div class="flex-none min-w-8 text-right"> 133 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 134 + </div> 135 + <div class="break-words max-w-full"> 136 + <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 137 + {{ .Title -}} 138 + </a> 139 + on 140 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 141 + {{$repoUrl}} 142 + </a> 143 + </div> 144 + </div> 145 + {{ end }} 146 + </div> 147 + </details> 148 + {{ end }} 149 + {{ end }} 150 + 151 + {{ define "pullEvents" }} 152 + {{ $items := .Items }} 153 + {{ $stats := .Stats }} 154 + {{ if gt (len $items) 0 }} 155 + <details> 156 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 157 + <div class="flex flex-wrap items-center gap-2"> 158 + {{ i "git-pull-request" "w-4 h-4" }} 159 + 160 + <div> 161 + created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 162 + </div> 163 + 164 + {{ if gt $stats.Open 0 }} 165 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 166 + {{$stats.Open}} open 167 + </span> 168 + {{ end }} 169 + 170 + {{ if gt $stats.Merged 0 }} 171 + <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 172 + {{$stats.Merged}} merged 173 + </span> 174 + {{ end }} 175 + 176 + 177 + {{ if gt $stats.Closed 0 }} 178 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 179 + {{$stats.Closed}} closed 180 + </span> 181 + {{ end }} 182 + 183 + </div> 184 + </summary> 185 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 186 + {{ range $items }} 187 + {{ $repoOwner := resolve .Repo.Did }} 188 + {{ $repoName := .Repo.Name }} 189 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 190 + 191 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 192 + {{ if .State.IsOpen }} 193 + <span class="text-green-600 dark:text-green-500"> 194 + {{ i "git-pull-request" "w-4 h-4" }} 195 + </span> 196 + {{ else if .State.IsMerged }} 197 + <span class="text-purple-600 dark:text-purple-500"> 198 + {{ i "git-merge" "w-4 h-4" }} 199 + </span> 200 + {{ else }} 201 + <span class="text-gray-600 dark:text-gray-300"> 202 + {{ i "git-pull-request-closed" "w-4 h-4" }} 203 + </span> 204 + {{ end }} 205 + <div class="flex-none min-w-8 text-right"> 206 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 207 + </div> 208 + <div class="break-words max-w-full"> 209 + <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 210 + {{ .Title -}} 211 + </a> 212 + on 213 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 214 + {{$repoUrl}} 215 + </a> 216 + </div> 217 + </div> 218 + {{ end }} 219 + </div> 220 + </details> 221 + {{ end }} 222 + {{ end }} 223 + 224 + {{ define "ownRepos" }} 225 + <div> 226 + <div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2"> 227 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 228 + class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 229 + <span>PINNED REPOS</span> 230 + </a> 231 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 232 + <button 233 + hx-get="profile/edit-pins" 234 + hx-target="#all-repos" 235 + class="py-0 font-normal text-sm flex gap-2 items-center group"> 236 + {{ i "pencil" "w-3 h-3" }} 237 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 238 + </button> 239 + {{ end }} 240 + </div> 241 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 242 + {{ range .Repos }} 243 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 244 + {{ template "user/fragments/repoCard" (list $ . false) }} 245 + </div> 246 + {{ else }} 247 + <p class="dark:text-white">This user does not have any pinned repos.</p> 248 + {{ end }} 249 + </div> 250 + </div> 251 + {{ end }} 252 + 253 + {{ define "collaboratingRepos" }} 254 + {{ if gt (len .CollaboratingRepos) 0 }} 255 + <div> 256 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p> 257 + <div id="collaborating" class="grid grid-cols-1 gap-4"> 258 + {{ range .CollaboratingRepos }} 259 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 260 + {{ template "user/fragments/repoCard" (list $ . true) }} 261 + </div> 262 + {{ else }} 263 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 264 + {{ end }} 265 + </div> 266 + </div> 267 + {{ end }} 268 + {{ end }} 269 +
-318
appview/pages/templates/user/profile.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 - <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - <div class="grid grid-cols-1 gap-4"> 14 - {{ template "user/fragments/profileCard" .Card }} 15 - {{ block "punchcard" .Punchcard }} {{ end }} 16 - </div> 17 - </div> 18 - <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 19 - <div class="grid grid-cols-1 gap-4"> 20 - {{ block "ownRepos" . }}{{ end }} 21 - {{ block "collaboratingRepos" . }}{{ end }} 22 - </div> 23 - </div> 24 - <div class="md:col-span-4 order-3 md:order-3"> 25 - {{ block "profileTimeline" . }}{{ end }} 26 - </div> 27 - </div> 28 - {{ end }} 29 - 30 - {{ define "profileTimeline" }} 31 - <p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p> 32 - <div class="flex flex-col gap-4 relative"> 33 - {{ with .ProfileTimeline }} 34 - {{ range $idx, $byMonth := .ByMonth }} 35 - {{ with $byMonth }} 36 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 37 - {{ if eq $idx 0 }} 38 - 39 - {{ else }} 40 - {{ $s := "s" }} 41 - {{ if eq $idx 1 }} 42 - {{ $s = "" }} 43 - {{ end }} 44 - <p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p> 45 - {{ end }} 46 - 47 - {{ if .IsEmpty }} 48 - <div class="text-gray-500 dark:text-gray-400"> 49 - No activity for this month 50 - </div> 51 - {{ else }} 52 - <div class="flex flex-col gap-1"> 53 - {{ block "repoEvents" .RepoEvents }} {{ end }} 54 - {{ block "issueEvents" .IssueEvents }} {{ end }} 55 - {{ block "pullEvents" .PullEvents }} {{ end }} 56 - </div> 57 - {{ end }} 58 - </div> 59 - 60 - {{ end }} 61 - {{ else }} 62 - <p class="dark:text-white">This user does not have any activity yet.</p> 63 - {{ end }} 64 - {{ end }} 65 - </div> 66 - {{ end }} 67 - 68 - {{ define "repoEvents" }} 69 - {{ if gt (len .) 0 }} 70 - <details> 71 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 72 - <div class="flex flex-wrap items-center gap-2"> 73 - {{ i "book-plus" "w-4 h-4" }} 74 - created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 75 - </div> 76 - </summary> 77 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 78 - {{ range . }} 79 - <div class="flex flex-wrap items-center gap-2"> 80 - <span class="text-gray-500 dark:text-gray-400"> 81 - {{ if .Source }} 82 - {{ i "git-fork" "w-4 h-4" }} 83 - {{ else }} 84 - {{ i "book-plus" "w-4 h-4" }} 85 - {{ end }} 86 - </span> 87 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 88 - {{- .Repo.Name -}} 89 - </a> 90 - </div> 91 - {{ end }} 92 - </div> 93 - </details> 94 - {{ end }} 95 - {{ end }} 96 - 97 - {{ define "issueEvents" }} 98 - {{ $items := .Items }} 99 - {{ $stats := .Stats }} 100 - 101 - {{ if gt (len $items) 0 }} 102 - <details> 103 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 104 - <div class="flex flex-wrap items-center gap-2"> 105 - {{ i "circle-dot" "w-4 h-4" }} 106 - 107 - <div> 108 - created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 109 - </div> 110 - 111 - {{ if gt $stats.Open 0 }} 112 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 113 - {{$stats.Open}} open 114 - </span> 115 - {{ end }} 116 - 117 - {{ if gt $stats.Closed 0 }} 118 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 119 - {{$stats.Closed}} closed 120 - </span> 121 - {{ end }} 122 - 123 - </div> 124 - </summary> 125 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 126 - {{ range $items }} 127 - {{ $repoOwner := resolve .Metadata.Repo.Did }} 128 - {{ $repoName := .Metadata.Repo.Name }} 129 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 130 - 131 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 132 - {{ if .Open }} 133 - <span class="text-green-600 dark:text-green-500"> 134 - {{ i "circle-dot" "w-4 h-4" }} 135 - </span> 136 - {{ else }} 137 - <span class="text-gray-500 dark:text-gray-400"> 138 - {{ i "ban" "w-4 h-4" }} 139 - </span> 140 - {{ end }} 141 - <div class="flex-none min-w-8 text-right"> 142 - <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 143 - </div> 144 - <div class="break-words max-w-full"> 145 - <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 146 - {{ .Title -}} 147 - </a> 148 - on 149 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 150 - {{$repoUrl}} 151 - </a> 152 - </div> 153 - </div> 154 - {{ end }} 155 - </div> 156 - </details> 157 - {{ end }} 158 - {{ end }} 159 - 160 - {{ define "pullEvents" }} 161 - {{ $items := .Items }} 162 - {{ $stats := .Stats }} 163 - {{ if gt (len $items) 0 }} 164 - <details> 165 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 166 - <div class="flex flex-wrap items-center gap-2"> 167 - {{ i "git-pull-request" "w-4 h-4" }} 168 - 169 - <div> 170 - created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 171 - </div> 172 - 173 - {{ if gt $stats.Open 0 }} 174 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 175 - {{$stats.Open}} open 176 - </span> 177 - {{ end }} 178 - 179 - {{ if gt $stats.Merged 0 }} 180 - <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 181 - {{$stats.Merged}} merged 182 - </span> 183 - {{ end }} 184 - 185 - 186 - {{ if gt $stats.Closed 0 }} 187 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 188 - {{$stats.Closed}} closed 189 - </span> 190 - {{ end }} 191 - 192 - </div> 193 - </summary> 194 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 195 - {{ range $items }} 196 - {{ $repoOwner := resolve .Repo.Did }} 197 - {{ $repoName := .Repo.Name }} 198 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 199 - 200 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 201 - {{ if .State.IsOpen }} 202 - <span class="text-green-600 dark:text-green-500"> 203 - {{ i "git-pull-request" "w-4 h-4" }} 204 - </span> 205 - {{ else if .State.IsMerged }} 206 - <span class="text-purple-600 dark:text-purple-500"> 207 - {{ i "git-merge" "w-4 h-4" }} 208 - </span> 209 - {{ else }} 210 - <span class="text-gray-600 dark:text-gray-300"> 211 - {{ i "git-pull-request-closed" "w-4 h-4" }} 212 - </span> 213 - {{ end }} 214 - <div class="flex-none min-w-8 text-right"> 215 - <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 216 - </div> 217 - <div class="break-words max-w-full"> 218 - <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 219 - {{ .Title -}} 220 - </a> 221 - on 222 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 223 - {{$repoUrl}} 224 - </a> 225 - </div> 226 - </div> 227 - {{ end }} 228 - </div> 229 - </details> 230 - {{ end }} 231 - {{ end }} 232 - 233 - {{ define "ownRepos" }} 234 - <div> 235 - <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 236 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 237 - class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 238 - <span>PINNED REPOS</span> 239 - <span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 240 - view all {{ i "chevron-right" "w-4 h-4" }} 241 - </span> 242 - </a> 243 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 244 - <button 245 - hx-get="profile/edit-pins" 246 - hx-target="#all-repos" 247 - class="btn py-0 font-normal text-sm flex gap-2 items-center group"> 248 - {{ i "pencil" "w-3 h-3" }} 249 - edit 250 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 251 - </button> 252 - {{ end }} 253 - </div> 254 - <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 255 - {{ range .Repos }} 256 - {{ template "user/fragments/repoCard" (list $ . false) }} 257 - {{ else }} 258 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 259 - {{ end }} 260 - </div> 261 - </div> 262 - {{ end }} 263 - 264 - {{ define "collaboratingRepos" }} 265 - {{ if gt (len .CollaboratingRepos) 0 }} 266 - <div> 267 - <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 268 - <div id="collaborating" class="grid grid-cols-1 gap-4"> 269 - {{ range .CollaboratingRepos }} 270 - {{ template "user/fragments/repoCard" (list $ . true) }} 271 - {{ else }} 272 - <p class="px-6 dark:text-white">This user is not collaborating.</p> 273 - {{ end }} 274 - </div> 275 - </div> 276 - {{ end }} 277 - {{ end }} 278 - 279 - {{ define "punchcard" }} 280 - {{ $now := now }} 281 - <div> 282 - <p class="p-2 flex gap-2 text-sm font-bold dark:text-white"> 283 - PUNCHCARD 284 - <span class="font-normal text-sm text-gray-500 dark:text-gray-400 "> 285 - {{ .Total | int64 | commaFmt }} commits 286 - </span> 287 - </p> 288 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 289 - <div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full"> 290 - {{ range .Punches }} 291 - {{ $count := .Count }} 292 - {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 293 - {{ if lt $count 1 }} 294 - {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 295 - {{ else if lt $count 2 }} 296 - {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 297 - {{ else if lt $count 4 }} 298 - {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 299 - {{ else if lt $count 8 }} 300 - {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 301 - {{ else }} 302 - {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 303 - {{ end }} 304 - 305 - {{ if .Date.After $now }} 306 - {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 307 - {{ end }} 308 - <div class="w-full h-full flex justify-center items-center"> 309 - <div 310 - class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 311 - title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 312 - </div> 313 - </div> 314 - {{ end }} 315 - </div> 316 - </div> 317 - </div> 318 - {{ end }}
+7 -18
appview/pages/templates/user/repos.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · repos {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "ownRepos" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "ownRepos" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "ownRepos" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 10 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 11 {{ range .Repos }} 25 - {{ template "user/fragments/repoCard" (list $ . false) }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . false) }} 14 + </div> 26 15 {{ else }} 27 16 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 28 17 {{ end }}
+2 -2
appview/pages/templates/user/settings/emails.html
··· 4 4 <div class="p-6"> 5 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 9 <div class="col-span-1"> 10 10 {{ template "user/settings/fragments/sidebar" . }} 11 11 </div>
+2 -2
appview/pages/templates/user/settings/keys.html
··· 4 4 <div class="p-6"> 5 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 9 <div class="col-span-1"> 10 10 {{ template "user/settings/fragments/sidebar" . }} 11 11 </div>
+173
appview/pages/templates/user/settings/notifications.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "notificationSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "notificationSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Choose which notifications you want to receive when activity happens on your repositories and profile. 25 + </p> 26 + </div> 27 + </div> 28 + 29 + <form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6"> 30 + 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + <div class="flex items-center justify-between p-2"> 33 + <div class="flex items-center gap-2"> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-bold">Repository starred</span> 36 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 37 + <span>When someone stars your repository.</span> 38 + </div> 39 + </div> 40 + </div> 41 + <label class="flex items-center gap-2"> 42 + <input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}> 43 + </label> 44 + </div> 45 + 46 + <div class="flex items-center justify-between p-2"> 47 + <div class="flex items-center gap-2"> 48 + <div class="flex flex-col gap-1"> 49 + <span class="font-bold">New issues</span> 50 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 51 + <span>When someone creates an issue on your repository.</span> 52 + </div> 53 + </div> 54 + </div> 55 + <label class="flex items-center gap-2"> 56 + <input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}> 57 + </label> 58 + </div> 59 + 60 + <div class="flex items-center justify-between p-2"> 61 + <div class="flex items-center gap-2"> 62 + <div class="flex flex-col gap-1"> 63 + <span class="font-bold">Issue comments</span> 64 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 65 + <span>When someone comments on an issue you're involved with.</span> 66 + </div> 67 + </div> 68 + </div> 69 + <label class="flex items-center gap-2"> 70 + <input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}> 71 + </label> 72 + </div> 73 + 74 + <div class="flex items-center justify-between p-2"> 75 + <div class="flex items-center gap-2"> 76 + <div class="flex flex-col gap-1"> 77 + <span class="font-bold">Issue closed</span> 78 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 79 + <span>When an issue on your repository is closed.</span> 80 + </div> 81 + </div> 82 + </div> 83 + <label class="flex items-center gap-2"> 84 + <input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}> 85 + </label> 86 + </div> 87 + 88 + <div class="flex items-center justify-between p-2"> 89 + <div class="flex items-center gap-2"> 90 + <div class="flex flex-col gap-1"> 91 + <span class="font-bold">New pull requests</span> 92 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 93 + <span>When someone creates a pull request on your repository.</span> 94 + </div> 95 + </div> 96 + </div> 97 + <label class="flex items-center gap-2"> 98 + <input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}> 99 + </label> 100 + </div> 101 + 102 + <div class="flex items-center justify-between p-2"> 103 + <div class="flex items-center gap-2"> 104 + <div class="flex flex-col gap-1"> 105 + <span class="font-bold">Pull request comments</span> 106 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 107 + <span>When someone comments on a pull request you're involved with.</span> 108 + </div> 109 + </div> 110 + </div> 111 + <label class="flex items-center gap-2"> 112 + <input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}> 113 + </label> 114 + </div> 115 + 116 + <div class="flex items-center justify-between p-2"> 117 + <div class="flex items-center gap-2"> 118 + <div class="flex flex-col gap-1"> 119 + <span class="font-bold">Pull request merged</span> 120 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 121 + <span>When your pull request is merged.</span> 122 + </div> 123 + </div> 124 + </div> 125 + <label class="flex items-center gap-2"> 126 + <input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}> 127 + </label> 128 + </div> 129 + 130 + <div class="flex items-center justify-between p-2"> 131 + <div class="flex items-center gap-2"> 132 + <div class="flex flex-col gap-1"> 133 + <span class="font-bold">New followers</span> 134 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 135 + <span>When someone follows you.</span> 136 + </div> 137 + </div> 138 + </div> 139 + <label class="flex items-center gap-2"> 140 + <input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}> 141 + </label> 142 + </div> 143 + 144 + <div class="flex items-center justify-between p-2"> 145 + <div class="flex items-center gap-2"> 146 + <div class="flex flex-col gap-1"> 147 + <span class="font-bold">Email notifications</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>Receive notifications via email in addition to in-app notifications.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}> 155 + </label> 156 + </div> 157 + </div> 158 + 159 + <div class="flex justify-end pt-2"> 160 + <button 161 + type="submit" 162 + class="btn-create flex items-center gap-2 group" 163 + > 164 + {{ i "save" "w-4 h-4" }} 165 + save 166 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 167 + </button> 168 + </div> 169 + <div id="settings-notifications-success"></div> 170 + 171 + <div id="settings-notifications-error" class="error"></div> 172 + </form> 173 + {{ end }}
+3 -5
appview/pages/templates/user/settings/profile.html
··· 4 4 <div class="p-6"> 5 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 9 <div class="col-span-1"> 10 10 {{ template "user/settings/fragments/sidebar" . }} 11 11 </div> ··· 33 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 34 <span>Handle</span> 35 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 36 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 37 + {{ resolve .LoggedInUser.Did }} 39 38 </span> 40 - {{ end }} 41 39 </div> 42 40 </div> 43 41 <div class="flex items-center justify-between p-4">
+11 -4
appview/pages/templates/user/signup.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <meta property="og:title" content="signup · tangled" /> 8 - <meta property="og:url" content="https://tangled.sh/signup" /> 8 + <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 13 16 </head> 14 17 <body class="flex items-center justify-center min-h-screen"> 15 18 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 19 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 20 + {{ template "fragments/logotype" }} 21 + </h1> 17 22 <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 23 <form 19 24 class="mt-4 max-w-sm mx-auto" ··· 37 42 invite code, desired username, and password in the next 38 43 page to complete your registration. 39 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 40 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 41 49 <span>join now</span> 42 50 </button> 43 51 </form> 44 52 <p class="text-sm text-gray-500"> 45 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 46 54 </p> 47 55 48 56 <p id="signup-msg" class="error w-full"></p> ··· 50 58 </body> 51 59 </html> 52 60 {{ end }} 53 -
+19
appview/pages/templates/user/starred.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · repos {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "starredRepos" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "starredRepos" }} 10 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Repos }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . true) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any starred repos yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }}
+45
appview/pages/templates/user/strings.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · strings {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "allStrings" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "allStrings" }} 10 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Strings }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "singleString" (list $ .) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "singleString" }} 22 + {{ $root := index . 0 }} 23 + {{ $s := index . 1 }} 24 + <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 + <div class="font-medium dark:text-white flex gap-2 items-center"> 26 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 + </div> 28 + {{ with $s.Description }} 29 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 30 + {{ . }} 31 + </div> 32 + {{ end }} 33 + 34 + {{ $stat := $s.Stats }} 35 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 36 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 37 + <span class="select-none [&:before]:content-['·']"></span> 38 + {{ with $s.Edited }} 39 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 40 + {{ else }} 41 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 42 + {{ end }} 43 + </div> 44 + </div> 45 + {{ end }}
+1 -1
appview/pagination/page.go
··· 8 8 func FirstPage() Page { 9 9 return Page{ 10 10 Offset: 0, 11 - Limit: 10, 11 + Limit: 30, 12 12 } 13 13 } 14 14
+12 -11
appview/pipelines/pipelines.go
··· 9 9 "strings" 10 10 "time" 11 11 12 - "tangled.sh/tangled.sh/core/appview/config" 13 - "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/oauth" 15 - "tangled.sh/tangled.sh/core/appview/pages" 16 - "tangled.sh/tangled.sh/core/appview/reporesolver" 17 - "tangled.sh/tangled.sh/core/eventconsumer" 18 - "tangled.sh/tangled.sh/core/idresolver" 19 - "tangled.sh/tangled.sh/core/log" 20 - "tangled.sh/tangled.sh/core/rbac" 21 - spindlemodel "tangled.sh/tangled.sh/core/spindle/models" 12 + "tangled.org/core/appview/config" 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/oauth" 15 + "tangled.org/core/appview/pages" 16 + "tangled.org/core/appview/reporesolver" 17 + "tangled.org/core/eventconsumer" 18 + "tangled.org/core/idresolver" 19 + "tangled.org/core/log" 20 + "tangled.org/core/rbac" 21 + spindlemodel "tangled.org/core/spindle/models" 22 22 23 23 "github.com/go-chi/chi/v5" 24 24 "github.com/gorilla/websocket" ··· 48 48 ) *Pipelines { 49 49 logger := log.New("pipelines") 50 50 51 - return &Pipelines{oauth: oauth, 51 + return &Pipelines{ 52 + oauth: oauth, 52 53 repoResolver: repoResolver, 53 54 pages: pages, 54 55 idResolver: idResolver,
+1 -1
appview/pipelines/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
-131
appview/posthog/notifier.go
··· 1 - package posthog_service 2 - 3 - import ( 4 - "context" 5 - "log" 6 - 7 - "github.com/posthog/posthog-go" 8 - "tangled.sh/tangled.sh/core/appview/db" 9 - "tangled.sh/tangled.sh/core/appview/notify" 10 - ) 11 - 12 - type posthogNotifier struct { 13 - client posthog.Client 14 - notify.BaseNotifier 15 - } 16 - 17 - func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 - return &posthogNotifier{ 19 - client, 20 - notify.BaseNotifier{}, 21 - } 22 - } 23 - 24 - var _ notify.Notifier = &posthogNotifier{} 25 - 26 - func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 27 - err := n.client.Enqueue(posthog.Capture{ 28 - DistinctId: repo.Did, 29 - Event: "new_repo", 30 - Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 - }) 32 - if err != nil { 33 - log.Println("failed to enqueue posthog event:", err) 34 - } 35 - } 36 - 37 - func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) { 38 - err := n.client.Enqueue(posthog.Capture{ 39 - DistinctId: star.StarredByDid, 40 - Event: "star", 41 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 - }) 43 - if err != nil { 44 - log.Println("failed to enqueue posthog event:", err) 45 - } 46 - } 47 - 48 - func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) { 49 - err := n.client.Enqueue(posthog.Capture{ 50 - DistinctId: star.StarredByDid, 51 - Event: "unstar", 52 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 - }) 54 - if err != nil { 55 - log.Println("failed to enqueue posthog event:", err) 56 - } 57 - } 58 - 59 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 - err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.OwnerDid, 62 - Event: "new_issue", 63 - Properties: posthog.Properties{ 64 - "repo_at": issue.RepoAt.String(), 65 - "issue_id": issue.IssueId, 66 - }, 67 - }) 68 - if err != nil { 69 - log.Println("failed to enqueue posthog event:", err) 70 - } 71 - } 72 - 73 - func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) { 74 - err := n.client.Enqueue(posthog.Capture{ 75 - DistinctId: pull.OwnerDid, 76 - Event: "new_pull", 77 - Properties: posthog.Properties{ 78 - "repo_at": pull.RepoAt, 79 - "pull_id": pull.PullId, 80 - }, 81 - }) 82 - if err != nil { 83 - log.Println("failed to enqueue posthog event:", err) 84 - } 85 - } 86 - 87 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 88 - err := n.client.Enqueue(posthog.Capture{ 89 - DistinctId: comment.OwnerDid, 90 - Event: "new_pull_comment", 91 - Properties: posthog.Properties{ 92 - "repo_at": comment.RepoAt, 93 - "pull_id": comment.PullId, 94 - }, 95 - }) 96 - if err != nil { 97 - log.Println("failed to enqueue posthog event:", err) 98 - } 99 - } 100 - 101 - func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 102 - err := n.client.Enqueue(posthog.Capture{ 103 - DistinctId: follow.UserDid, 104 - Event: "follow", 105 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 106 - }) 107 - if err != nil { 108 - log.Println("failed to enqueue posthog event:", err) 109 - } 110 - } 111 - 112 - func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 113 - err := n.client.Enqueue(posthog.Capture{ 114 - DistinctId: follow.UserDid, 115 - Event: "unfollow", 116 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 117 - }) 118 - if err != nil { 119 - log.Println("failed to enqueue posthog event:", err) 120 - } 121 - } 122 - 123 - func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 124 - err := n.client.Enqueue(posthog.Capture{ 125 - DistinctId: profile.Did, 126 - Event: "edit_profile", 127 - }) 128 - if err != nil { 129 - log.Println("failed to enqueue posthog event:", err) 130 - } 131 - }
+452 -188
appview/pulls/pulls.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "encoding/json" 5 6 "errors" 6 7 "fmt" 7 8 "log" 8 9 "net/http" 10 + "slices" 9 11 "sort" 10 12 "strconv" 11 13 "strings" 12 14 "time" 13 15 14 - "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/appview/config" 16 - "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/notify" 18 - "tangled.sh/tangled.sh/core/appview/oauth" 19 - "tangled.sh/tangled.sh/core/appview/pages" 20 - "tangled.sh/tangled.sh/core/appview/pages/markup" 21 - "tangled.sh/tangled.sh/core/appview/reporesolver" 22 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 23 - "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 - "tangled.sh/tangled.sh/core/patchutil" 26 - "tangled.sh/tangled.sh/core/tid" 27 - "tangled.sh/tangled.sh/core/types" 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/appview/config" 18 + "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/models" 20 + "tangled.org/core/appview/notify" 21 + "tangled.org/core/appview/oauth" 22 + "tangled.org/core/appview/pages" 23 + "tangled.org/core/appview/pages/markup" 24 + "tangled.org/core/appview/reporesolver" 25 + "tangled.org/core/appview/xrpcclient" 26 + "tangled.org/core/idresolver" 27 + "tangled.org/core/patchutil" 28 + "tangled.org/core/rbac" 29 + "tangled.org/core/tid" 30 + "tangled.org/core/types" 28 31 29 32 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 33 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 42 45 db *db.DB 43 46 config *config.Config 44 47 notifier notify.Notifier 48 + enforcer *rbac.Enforcer 45 49 } 46 50 47 51 func New( ··· 52 56 db *db.DB, 53 57 config *config.Config, 54 58 notifier notify.Notifier, 59 + enforcer *rbac.Enforcer, 55 60 ) *Pulls { 56 61 return &Pulls{ 57 62 oauth: oauth, ··· 61 66 db: db, 62 67 config: config, 63 68 notifier: notifier, 69 + enforcer: enforcer, 64 70 } 65 71 } 66 72 ··· 75 81 return 76 82 } 77 83 78 - pull, ok := r.Context().Value("pull").(*db.Pull) 84 + pull, ok := r.Context().Value("pull").(*models.Pull) 79 85 if !ok { 80 86 log.Println("failed to get pull") 81 87 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 83 89 } 84 90 85 91 // can be nil if this pull is not stacked 86 - stack, _ := r.Context().Value("stack").(db.Stack) 92 + stack, _ := r.Context().Value("stack").(models.Stack) 87 93 88 94 roundNumberStr := chi.URLParam(r, "round") 89 95 roundNumber, err := strconv.Atoi(roundNumberStr) ··· 97 103 } 98 104 99 105 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 106 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 100 107 resubmitResult := pages.Unknown 101 108 if user.Did == pull.OwnerDid { 102 - resubmitResult = s.resubmitCheck(f, pull, stack) 109 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 103 110 } 104 111 105 112 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 106 - LoggedInUser: user, 107 - RepoInfo: f.RepoInfo(user), 108 - Pull: pull, 109 - RoundNumber: roundNumber, 110 - MergeCheck: mergeCheckResponse, 111 - ResubmitCheck: resubmitResult, 112 - Stack: stack, 113 + LoggedInUser: user, 114 + RepoInfo: f.RepoInfo(user), 115 + Pull: pull, 116 + RoundNumber: roundNumber, 117 + MergeCheck: mergeCheckResponse, 118 + ResubmitCheck: resubmitResult, 119 + BranchDeleteStatus: branchDeleteStatus, 120 + Stack: stack, 113 121 }) 114 122 return 115 123 } ··· 123 131 return 124 132 } 125 133 126 - pull, ok := r.Context().Value("pull").(*db.Pull) 134 + pull, ok := r.Context().Value("pull").(*models.Pull) 127 135 if !ok { 128 136 log.Println("failed to get pull") 129 137 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 131 139 } 132 140 133 141 // can be nil if this pull is not stacked 134 - stack, _ := r.Context().Value("stack").(db.Stack) 135 - abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull) 142 + stack, _ := r.Context().Value("stack").(models.Stack) 143 + abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 136 144 137 145 totalIdents := 1 138 146 for _, submission := range pull.Submissions { ··· 152 160 } 153 161 154 162 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 163 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 155 164 resubmitResult := pages.Unknown 156 165 if user != nil && user.Did == pull.OwnerDid { 157 - resubmitResult = s.resubmitCheck(f, pull, stack) 166 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 158 167 } 159 168 160 169 repoInfo := f.RepoInfo(user) 161 170 162 - m := make(map[string]db.Pipeline) 171 + m := make(map[string]models.Pipeline) 163 172 164 173 var shas []string 165 174 for _, s := range pull.Submissions { ··· 188 197 m[p.Sha] = p 189 198 } 190 199 191 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 200 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 192 201 if err != nil { 193 202 log.Println("failed to get pull reactions") 194 203 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 195 204 } 196 205 197 - userReactions := map[db.ReactionKind]bool{} 206 + userReactions := map[models.ReactionKind]bool{} 198 207 if user != nil { 199 208 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 200 209 } 201 210 211 + labelDefs, err := db.GetLabelDefinitions( 212 + s.db, 213 + db.FilterIn("at_uri", f.Repo.Labels), 214 + db.FilterContains("scope", tangled.RepoPullNSID), 215 + ) 216 + if err != nil { 217 + log.Println("failed to fetch labels", err) 218 + s.pages.Error503(w) 219 + return 220 + } 221 + 222 + defs := make(map[string]*models.LabelDefinition) 223 + for _, l := range labelDefs { 224 + defs[l.AtUri().String()] = &l 225 + } 226 + 202 227 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 203 - LoggedInUser: user, 204 - RepoInfo: repoInfo, 205 - Pull: pull, 206 - Stack: stack, 207 - AbandonedPulls: abandonedPulls, 208 - MergeCheck: mergeCheckResponse, 209 - ResubmitCheck: resubmitResult, 210 - Pipelines: m, 228 + LoggedInUser: user, 229 + RepoInfo: repoInfo, 230 + Pull: pull, 231 + Stack: stack, 232 + AbandonedPulls: abandonedPulls, 233 + BranchDeleteStatus: branchDeleteStatus, 234 + MergeCheck: mergeCheckResponse, 235 + ResubmitCheck: resubmitResult, 236 + Pipelines: m, 211 237 212 - OrderedReactionKinds: db.OrderedReactionKinds, 213 - Reactions: reactionCountMap, 238 + OrderedReactionKinds: models.OrderedReactionKinds, 239 + Reactions: reactionMap, 214 240 UserReacted: userReactions, 241 + 242 + LabelDefs: defs, 215 243 }) 216 244 } 217 245 218 - func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 219 - if pull.State == db.PullMerged { 246 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 247 + if pull.State == models.PullMerged { 220 248 return types.MergeCheckResponse{} 221 249 } 222 250 ··· 282 310 return result 283 311 } 284 312 285 - func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 286 - if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 313 + func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus { 314 + if pull.State != models.PullMerged { 315 + return nil 316 + } 317 + 318 + user := s.oauth.GetUser(r) 319 + if user == nil { 320 + return nil 321 + } 322 + 323 + var branch string 324 + var repo *models.Repo 325 + // check if the branch exists 326 + // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 327 + if pull.IsBranchBased() { 328 + branch = pull.PullSource.Branch 329 + repo = &f.Repo 330 + } else if pull.IsForkBased() { 331 + branch = pull.PullSource.Branch 332 + repo = pull.PullSource.Repo 333 + } else { 334 + return nil 335 + } 336 + 337 + // deleted fork 338 + if repo == nil { 339 + return nil 340 + } 341 + 342 + // user can only delete branch if they are a collaborator in the repo that the branch belongs to 343 + perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 344 + if !slices.Contains(perms, "repo:push") { 345 + return nil 346 + } 347 + 348 + scheme := "http" 349 + if !s.config.Core.Dev { 350 + scheme = "https" 351 + } 352 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 353 + xrpcc := &indigoxrpc.Client{ 354 + Host: host, 355 + } 356 + 357 + resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 358 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 359 + return nil 360 + } 361 + 362 + return &models.BranchDeleteStatus{ 363 + Repo: repo, 364 + Branch: resp.Name, 365 + } 366 + } 367 + 368 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 369 + if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 287 370 return pages.Unknown 288 371 } 289 372 ··· 307 390 repoName = f.Name 308 391 } 309 392 310 - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 311 - if err != nil { 312 - log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 313 - return pages.Unknown 393 + scheme := "http" 394 + if !s.config.Core.Dev { 395 + scheme = "https" 396 + } 397 + host := fmt.Sprintf("%s://%s", scheme, knot) 398 + xrpcc := &indigoxrpc.Client{ 399 + Host: host, 314 400 } 315 401 316 - result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 402 + repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 403 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 317 404 if err != nil { 405 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 406 + log.Println("failed to call XRPC repo.branches", xrpcerr) 407 + return pages.Unknown 408 + } 318 409 log.Println("failed to reach knotserver", err) 319 410 return pages.Unknown 320 411 } 412 + 413 + targetBranch := branchResp 321 414 322 415 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 323 416 ··· 326 419 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 327 420 } 328 421 329 - if latestSourceRev != result.Branch.Hash { 422 + if latestSourceRev != targetBranch.Hash { 330 423 return pages.ShouldResubmit 331 424 } 332 425 ··· 346 439 diffOpts.Split = true 347 440 } 348 441 349 - pull, ok := r.Context().Value("pull").(*db.Pull) 442 + pull, ok := r.Context().Value("pull").(*models.Pull) 350 443 if !ok { 351 444 log.Println("failed to get pull") 352 445 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 353 446 return 354 447 } 355 448 356 - stack, _ := r.Context().Value("stack").(db.Stack) 449 + stack, _ := r.Context().Value("stack").(models.Stack) 357 450 358 451 roundId := chi.URLParam(r, "round") 359 452 roundIdInt, err := strconv.Atoi(roundId) ··· 393 486 diffOpts.Split = true 394 487 } 395 488 396 - pull, ok := r.Context().Value("pull").(*db.Pull) 489 + pull, ok := r.Context().Value("pull").(*models.Pull) 397 490 if !ok { 398 491 log.Println("failed to get pull") 399 492 s.pages.Notice(w, "pull-error", "Failed to get pull.") ··· 441 534 } 442 535 443 536 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 444 - pull, ok := r.Context().Value("pull").(*db.Pull) 537 + pull, ok := r.Context().Value("pull").(*models.Pull) 445 538 if !ok { 446 539 log.Println("failed to get pull") 447 540 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 464 557 user := s.oauth.GetUser(r) 465 558 params := r.URL.Query() 466 559 467 - state := db.PullOpen 560 + state := models.PullOpen 468 561 switch params.Get("state") { 469 562 case "closed": 470 - state = db.PullClosed 563 + state = models.PullClosed 471 564 case "merged": 472 - state = db.PullMerged 565 + state = models.PullMerged 473 566 } 474 567 475 568 f, err := s.repoResolver.Resolve(r) ··· 490 583 } 491 584 492 585 for _, p := range pulls { 493 - var pullSourceRepo *db.Repo 586 + var pullSourceRepo *models.Repo 494 587 if p.PullSource != nil { 495 588 if p.PullSource.RepoAt != nil { 496 589 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) ··· 505 598 } 506 599 507 600 // we want to group all stacked PRs into just one list 508 - stacks := make(map[string]db.Stack) 601 + stacks := make(map[string]models.Stack) 509 602 var shas []string 510 603 n := 0 511 604 for _, p := range pulls { ··· 541 634 log.Printf("failed to fetch pipeline statuses: %s", err) 542 635 // non-fatal 543 636 } 544 - m := make(map[string]db.Pipeline) 637 + m := make(map[string]models.Pipeline) 545 638 for _, p := range ps { 546 639 m[p.Sha] = p 547 640 } 548 641 642 + labelDefs, err := db.GetLabelDefinitions( 643 + s.db, 644 + db.FilterIn("at_uri", f.Repo.Labels), 645 + db.FilterContains("scope", tangled.RepoPullNSID), 646 + ) 647 + if err != nil { 648 + log.Println("failed to fetch labels", err) 649 + s.pages.Error503(w) 650 + return 651 + } 652 + 653 + defs := make(map[string]*models.LabelDefinition) 654 + for _, l := range labelDefs { 655 + defs[l.AtUri().String()] = &l 656 + } 657 + 549 658 s.pages.RepoPulls(w, pages.RepoPullsParams{ 550 659 LoggedInUser: s.oauth.GetUser(r), 551 660 RepoInfo: f.RepoInfo(user), 552 661 Pulls: pulls, 662 + LabelDefs: defs, 553 663 FilteringBy: state, 554 664 Stacks: stacks, 555 665 Pipelines: m, ··· 564 674 return 565 675 } 566 676 567 - pull, ok := r.Context().Value("pull").(*db.Pull) 677 + pull, ok := r.Context().Value("pull").(*models.Pull) 568 678 if !ok { 569 679 log.Println("failed to get pull") 570 680 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 619 729 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 620 730 return 621 731 } 622 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 732 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 623 733 Collection: tangled.RepoPullCommentNSID, 624 734 Repo: user.Did, 625 735 Rkey: tid.TID(), ··· 637 747 return 638 748 } 639 749 640 - comment := &db.PullComment{ 750 + comment := &models.PullComment{ 641 751 OwnerDid: user.Did, 642 752 RepoAt: f.RepoAt().String(), 643 753 PullId: pull.PullId, ··· 678 788 679 789 switch r.Method { 680 790 case http.MethodGet: 681 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 682 - if err != nil { 683 - log.Printf("failed to create unsigned client for %s", f.Knot) 684 - s.pages.Error503(w) 685 - return 791 + scheme := "http" 792 + if !s.config.Core.Dev { 793 + scheme = "https" 794 + } 795 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 796 + xrpcc := &indigoxrpc.Client{ 797 + Host: host, 686 798 } 687 799 688 - result, err := us.Branches(f.OwnerDid(), f.Name) 800 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 801 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 689 802 if err != nil { 803 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 804 + log.Println("failed to call XRPC repo.branches", xrpcerr) 805 + s.pages.Error503(w) 806 + return 807 + } 690 808 log.Println("failed to fetch branches", err) 809 + return 810 + } 811 + 812 + var result types.RepoBranchesResponse 813 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 814 + log.Println("failed to decode XRPC response", err) 815 + s.pages.Error503(w) 691 816 return 692 817 } 693 818 ··· 752 877 return 753 878 } 754 879 755 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 756 - if err != nil { 757 - log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 758 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 759 - return 760 - } 880 + // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 881 + // if err != nil { 882 + // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 883 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 884 + // return 885 + // } 761 886 762 - caps, err := us.Capabilities() 763 - if err != nil { 764 - log.Println("error fetching knot caps", f.Knot, err) 765 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 766 - return 887 + // TODO: make capabilities an xrpc call 888 + caps := struct { 889 + PullRequests struct { 890 + FormatPatch bool 891 + BranchSubmissions bool 892 + ForkSubmissions bool 893 + PatchSubmissions bool 894 + } 895 + }{ 896 + PullRequests: struct { 897 + FormatPatch bool 898 + BranchSubmissions bool 899 + ForkSubmissions bool 900 + PatchSubmissions bool 901 + }{ 902 + FormatPatch: true, 903 + BranchSubmissions: true, 904 + ForkSubmissions: true, 905 + PatchSubmissions: true, 906 + }, 767 907 } 908 + 909 + // caps, err := us.Capabilities() 910 + // if err != nil { 911 + // log.Println("error fetching knot caps", f.Knot, err) 912 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 913 + // return 914 + // } 768 915 769 916 if !caps.PullRequests.FormatPatch { 770 917 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 806 953 sourceBranch string, 807 954 isStacked bool, 808 955 ) { 809 - // Generate a patch using /compare 810 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 811 - if err != nil { 812 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 813 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 814 - return 956 + scheme := "http" 957 + if !s.config.Core.Dev { 958 + scheme = "https" 959 + } 960 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 961 + xrpcc := &indigoxrpc.Client{ 962 + Host: host, 815 963 } 816 964 817 - comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 965 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 966 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 818 967 if err != nil { 968 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 969 + log.Println("failed to call XRPC repo.compare", xrpcerr) 970 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 971 + return 972 + } 819 973 log.Println("failed to compare", err) 820 974 s.pages.Notice(w, "pull", err.Error()) 821 975 return 822 976 } 823 977 978 + var comparison types.RepoFormatPatchResponse 979 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 980 + log.Println("failed to decode XRPC compare response", err) 981 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 982 + return 983 + } 984 + 824 985 sourceRev := comparison.Rev2 825 986 patch := comparison.Patch 826 987 ··· 829 990 return 830 991 } 831 992 832 - pullSource := &db.PullSource{ 993 + pullSource := &models.PullSource{ 833 994 Branch: sourceBranch, 834 995 } 835 996 recordPullSource := &tangled.RepoPull_Source{ ··· 869 1030 oauth.WithLxm(tangled.RepoHiddenRefNSID), 870 1031 oauth.WithDev(s.config.Core.Dev), 871 1032 ) 872 - if err != nil { 873 - log.Printf("failed to connect to knot server: %v", err) 874 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 875 - return 876 - } 877 - 878 - us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 879 - if err != nil { 880 - log.Println("failed to create unsigned client:", err) 881 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 882 - return 883 - } 884 1033 885 1034 resp, err := tangled.RepoHiddenRef( 886 1035 r.Context(), ··· 911 1060 // hiddenRef: hidden/feature-1/main (on repo-fork) 912 1061 // targetBranch: main (on repo-1) 913 1062 // sourceBranch: feature-1 (on repo-fork) 914 - comparison, err := us.Compare(fork.Did, fork.Name, hiddenRef, sourceBranch) 1063 + forkScheme := "http" 1064 + if !s.config.Core.Dev { 1065 + forkScheme = "https" 1066 + } 1067 + forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 1068 + forkXrpcc := &indigoxrpc.Client{ 1069 + Host: forkHost, 1070 + } 1071 + 1072 + forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name) 1073 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch) 915 1074 if err != nil { 1075 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1076 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1077 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1078 + return 1079 + } 916 1080 log.Println("failed to compare across branches", err) 917 1081 s.pages.Notice(w, "pull", err.Error()) 918 1082 return 919 1083 } 920 1084 1085 + var comparison types.RepoFormatPatchResponse 1086 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 1087 + log.Println("failed to decode XRPC compare response for fork", err) 1088 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1089 + return 1090 + } 1091 + 921 1092 sourceRev := comparison.Rev2 922 1093 patch := comparison.Patch 923 1094 ··· 929 1100 forkAtUri := fork.RepoAt() 930 1101 forkAtUriStr := forkAtUri.String() 931 1102 932 - pullSource := &db.PullSource{ 1103 + pullSource := &models.PullSource{ 933 1104 Branch: sourceBranch, 934 1105 RepoAt: &forkAtUri, 935 1106 } ··· 950 1121 title, body, targetBranch string, 951 1122 patch string, 952 1123 sourceRev string, 953 - pullSource *db.PullSource, 1124 + pullSource *models.PullSource, 954 1125 recordPullSource *tangled.RepoPull_Source, 955 1126 isStacked bool, 956 1127 ) { ··· 986 1157 987 1158 // We've already checked earlier if it's diff-based and title is empty, 988 1159 // so if it's still empty now, it's intentionally skipped owing to format-patch. 989 - if title == "" { 1160 + if title == "" || body == "" { 990 1161 formatPatches, err := patchutil.ExtractPatches(patch) 991 1162 if err != nil { 992 1163 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 997 1168 return 998 1169 } 999 1170 1000 - title = formatPatches[0].Title 1001 - body = formatPatches[0].Body 1171 + if title == "" { 1172 + title = formatPatches[0].Title 1173 + } 1174 + if body == "" { 1175 + body = formatPatches[0].Body 1176 + } 1002 1177 } 1003 1178 1004 1179 rkey := tid.TID() 1005 - initialSubmission := db.PullSubmission{ 1180 + initialSubmission := models.PullSubmission{ 1006 1181 Patch: patch, 1007 1182 SourceRev: sourceRev, 1008 1183 } 1009 - pull := &db.Pull{ 1184 + pull := &models.Pull{ 1010 1185 Title: title, 1011 1186 Body: body, 1012 1187 TargetBranch: targetBranch, 1013 1188 OwnerDid: user.Did, 1014 1189 RepoAt: f.RepoAt(), 1015 1190 Rkey: rkey, 1016 - Submissions: []*db.PullSubmission{ 1191 + Submissions: []*models.PullSubmission{ 1017 1192 &initialSubmission, 1018 1193 }, 1019 1194 PullSource: pullSource, ··· 1031 1206 return 1032 1207 } 1033 1208 1034 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1209 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1035 1210 Collection: tangled.RepoPullNSID, 1036 1211 Repo: user.Did, 1037 1212 Rkey: rkey, ··· 1042 1217 Repo: string(f.RepoAt()), 1043 1218 Branch: targetBranch, 1044 1219 }, 1045 - Patch: patch, 1046 - Source: recordPullSource, 1220 + Patch: patch, 1221 + Source: recordPullSource, 1222 + CreatedAt: time.Now().Format(time.RFC3339), 1047 1223 }, 1048 1224 }, 1049 1225 }) ··· 1072 1248 targetBranch string, 1073 1249 patch string, 1074 1250 sourceRev string, 1075 - pullSource *db.PullSource, 1251 + pullSource *models.PullSource, 1076 1252 ) { 1077 1253 // run some necessary checks for stacked-prs first 1078 1254 ··· 1128 1304 } 1129 1305 writes = append(writes, &write) 1130 1306 } 1131 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1307 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1132 1308 Repo: user.Did, 1133 1309 Writes: writes, 1134 1310 }) ··· 1211 1387 return 1212 1388 } 1213 1389 1214 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1390 + scheme := "http" 1391 + if !s.config.Core.Dev { 1392 + scheme = "https" 1393 + } 1394 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1395 + xrpcc := &indigoxrpc.Client{ 1396 + Host: host, 1397 + } 1398 + 1399 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1400 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1215 1401 if err != nil { 1216 - log.Printf("failed to create unsigned client for %s", f.Knot) 1217 - s.pages.Error503(w) 1402 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1403 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1404 + s.pages.Error503(w) 1405 + return 1406 + } 1407 + log.Println("failed to fetch branches", err) 1218 1408 return 1219 1409 } 1220 1410 1221 - result, err := us.Branches(f.OwnerDid(), f.Name) 1222 - if err != nil { 1223 - log.Println("failed to reach knotserver", err) 1411 + var result types.RepoBranchesResponse 1412 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1413 + log.Println("failed to decode XRPC response", err) 1414 + s.pages.Error503(w) 1224 1415 return 1225 1416 } 1226 1417 ··· 1278 1469 forkOwnerDid := repoString[0] 1279 1470 forkName := repoString[1] 1280 1471 // fork repo 1281 - repo, err := db.GetRepo(s.db, forkOwnerDid, forkName) 1472 + repo, err := db.GetRepo( 1473 + s.db, 1474 + db.FilterEq("did", forkOwnerDid), 1475 + db.FilterEq("name", forkName), 1476 + ) 1282 1477 if err != nil { 1283 - log.Println("failed to get repo", user.Did, forkVal) 1478 + log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) 1284 1479 return 1285 1480 } 1286 1481 1287 - sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1288 - if err != nil { 1289 - log.Printf("failed to create unsigned client for %s", repo.Knot) 1290 - s.pages.Error503(w) 1291 - return 1482 + sourceScheme := "http" 1483 + if !s.config.Core.Dev { 1484 + sourceScheme = "https" 1485 + } 1486 + sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1487 + sourceXrpcc := &indigoxrpc.Client{ 1488 + Host: sourceHost, 1292 1489 } 1293 1490 1294 - sourceResult, err := sourceBranchesClient.Branches(forkOwnerDid, repo.Name) 1491 + sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1492 + sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1295 1493 if err != nil { 1296 - log.Println("failed to reach knotserver for source branches", err) 1494 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1495 + log.Println("failed to call XRPC repo.branches for source", xrpcerr) 1496 + s.pages.Error503(w) 1497 + return 1498 + } 1499 + log.Println("failed to fetch source branches", err) 1297 1500 return 1298 1501 } 1299 1502 1300 - targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1301 - if err != nil { 1302 - log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1503 + // Decode source branches 1504 + var sourceBranches types.RepoBranchesResponse 1505 + if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1506 + log.Println("failed to decode source branches XRPC response", err) 1303 1507 s.pages.Error503(w) 1304 1508 return 1305 1509 } 1306 1510 1307 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 1511 + targetScheme := "http" 1512 + if !s.config.Core.Dev { 1513 + targetScheme = "https" 1514 + } 1515 + targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1516 + targetXrpcc := &indigoxrpc.Client{ 1517 + Host: targetHost, 1518 + } 1519 + 1520 + targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1521 + targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1308 1522 if err != nil { 1309 - log.Println("failed to reach knotserver for target branches", err) 1523 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1524 + log.Println("failed to call XRPC repo.branches for target", xrpcerr) 1525 + s.pages.Error503(w) 1526 + return 1527 + } 1528 + log.Println("failed to fetch target branches", err) 1310 1529 return 1311 1530 } 1312 1531 1313 - sourceBranches := sourceResult.Branches 1314 - sort.Slice(sourceBranches, func(i int, j int) bool { 1315 - return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) 1532 + // Decode target branches 1533 + var targetBranches types.RepoBranchesResponse 1534 + if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1535 + log.Println("failed to decode target branches XRPC response", err) 1536 + s.pages.Error503(w) 1537 + return 1538 + } 1539 + 1540 + sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1541 + return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1316 1542 }) 1317 1543 1318 1544 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1319 1545 RepoInfo: f.RepoInfo(user), 1320 - SourceBranches: sourceBranches, 1321 - TargetBranches: targetResult.Branches, 1546 + SourceBranches: sourceBranches.Branches, 1547 + TargetBranches: targetBranches.Branches, 1322 1548 }) 1323 1549 } 1324 1550 ··· 1330 1556 return 1331 1557 } 1332 1558 1333 - pull, ok := r.Context().Value("pull").(*db.Pull) 1559 + pull, ok := r.Context().Value("pull").(*models.Pull) 1334 1560 if !ok { 1335 1561 log.Println("failed to get pull") 1336 1562 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1361 1587 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1362 1588 user := s.oauth.GetUser(r) 1363 1589 1364 - pull, ok := r.Context().Value("pull").(*db.Pull) 1590 + pull, ok := r.Context().Value("pull").(*models.Pull) 1365 1591 if !ok { 1366 1592 log.Println("failed to get pull") 1367 1593 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1388 1614 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1389 1615 user := s.oauth.GetUser(r) 1390 1616 1391 - pull, ok := r.Context().Value("pull").(*db.Pull) 1617 + pull, ok := r.Context().Value("pull").(*models.Pull) 1392 1618 if !ok { 1393 1619 log.Println("failed to get pull") 1394 1620 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1413 1639 return 1414 1640 } 1415 1641 1416 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1417 - if err != nil { 1418 - log.Printf("failed to create client for %s: %s", f.Knot, err) 1419 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1420 - return 1642 + scheme := "http" 1643 + if !s.config.Core.Dev { 1644 + scheme = "https" 1645 + } 1646 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1647 + xrpcc := &indigoxrpc.Client{ 1648 + Host: host, 1421 1649 } 1422 1650 1423 - comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch) 1651 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1652 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1424 1653 if err != nil { 1654 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1655 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1656 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1657 + return 1658 + } 1425 1659 log.Printf("compare request failed: %s", err) 1426 1660 s.pages.Notice(w, "resubmit-error", err.Error()) 1661 + return 1662 + } 1663 + 1664 + var comparison types.RepoFormatPatchResponse 1665 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1666 + log.Println("failed to decode XRPC compare response", err) 1667 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1427 1668 return 1428 1669 } 1429 1670 ··· 1436 1677 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1437 1678 user := s.oauth.GetUser(r) 1438 1679 1439 - pull, ok := r.Context().Value("pull").(*db.Pull) 1680 + pull, ok := r.Context().Value("pull").(*models.Pull) 1440 1681 if !ok { 1441 1682 log.Println("failed to get pull") 1442 1683 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1463 1704 } 1464 1705 1465 1706 // extract patch by performing compare 1466 - ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1707 + forkScheme := "http" 1708 + if !s.config.Core.Dev { 1709 + forkScheme = "https" 1710 + } 1711 + forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1712 + forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1713 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1467 1714 if err != nil { 1468 - log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1715 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1716 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1717 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1718 + return 1719 + } 1720 + log.Printf("failed to compare branches: %s", err) 1721 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1722 + return 1723 + } 1724 + 1725 + var forkComparison types.RepoFormatPatchResponse 1726 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1727 + log.Println("failed to decode XRPC compare response for fork", err) 1469 1728 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1470 1729 return 1471 1730 } ··· 1501 1760 return 1502 1761 } 1503 1762 1504 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1505 - comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1506 - if err != nil { 1507 - log.Printf("failed to compare branches: %s", err) 1508 - s.pages.Notice(w, "resubmit-error", err.Error()) 1509 - return 1510 - } 1763 + // Use the fork comparison we already made 1764 + comparison := forkComparison 1511 1765 1512 1766 sourceRev := comparison.Rev2 1513 1767 patch := comparison.Patch ··· 1516 1770 } 1517 1771 1518 1772 // validate a resubmission against a pull request 1519 - func validateResubmittedPatch(pull *db.Pull, patch string) error { 1773 + func validateResubmittedPatch(pull *models.Pull, patch string) error { 1520 1774 if patch == "" { 1521 1775 return fmt.Errorf("Patch is empty.") 1522 1776 } ··· 1537 1791 r *http.Request, 1538 1792 f *reporesolver.ResolvedRepo, 1539 1793 user *oauth.User, 1540 - pull *db.Pull, 1794 + pull *models.Pull, 1541 1795 patch string, 1542 1796 sourceRev string, 1543 1797 ) { ··· 1581 1835 return 1582 1836 } 1583 1837 1584 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1838 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1585 1839 if err != nil { 1586 1840 // failed to get record 1587 1841 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1604 1858 } 1605 1859 } 1606 1860 1607 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1861 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1608 1862 Collection: tangled.RepoPullNSID, 1609 1863 Repo: user.Did, 1610 1864 Rkey: pull.Rkey, ··· 1616 1870 Repo: string(f.RepoAt()), 1617 1871 Branch: pull.TargetBranch, 1618 1872 }, 1619 - Patch: patch, // new patch 1620 - Source: recordPullSource, 1873 + Patch: patch, // new patch 1874 + Source: recordPullSource, 1875 + CreatedAt: time.Now().Format(time.RFC3339), 1621 1876 }, 1622 1877 }, 1623 1878 }) ··· 1641 1896 r *http.Request, 1642 1897 f *reporesolver.ResolvedRepo, 1643 1898 user *oauth.User, 1644 - pull *db.Pull, 1899 + pull *models.Pull, 1645 1900 patch string, 1646 1901 stackId string, 1647 1902 ) { 1648 1903 targetBranch := pull.TargetBranch 1649 1904 1650 - origStack, _ := r.Context().Value("stack").(db.Stack) 1905 + origStack, _ := r.Context().Value("stack").(models.Stack) 1651 1906 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1652 1907 if err != nil { 1653 1908 log.Println("failed to create resubmitted stack", err) ··· 1656 1911 } 1657 1912 1658 1913 // find the diff between the stacks, first, map them by changeId 1659 - origById := make(map[string]*db.Pull) 1660 - newById := make(map[string]*db.Pull) 1914 + origById := make(map[string]*models.Pull) 1915 + newById := make(map[string]*models.Pull) 1661 1916 for _, p := range origStack { 1662 1917 origById[p.ChangeId] = p 1663 1918 } ··· 1670 1925 // commits that got updated: corresponding pull is resubmitted & new round begins 1671 1926 // 1672 1927 // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1673 - additions := make(map[string]*db.Pull) 1674 - deletions := make(map[string]*db.Pull) 1928 + additions := make(map[string]*models.Pull) 1929 + deletions := make(map[string]*models.Pull) 1675 1930 unchanged := make(map[string]struct{}) 1676 1931 updated := make(map[string]struct{}) 1677 1932 ··· 1731 1986 // deleted pulls are marked as deleted in the DB 1732 1987 for _, p := range deletions { 1733 1988 // do not do delete already merged PRs 1734 - if p.State == db.PullMerged { 1989 + if p.State == models.PullMerged { 1735 1990 continue 1736 1991 } 1737 1992 ··· 1776 2031 np, _ := newById[id] 1777 2032 1778 2033 // do not update already merged PRs 1779 - if op.State == db.PullMerged { 2034 + if op.State == models.PullMerged { 1780 2035 continue 1781 2036 } 1782 2037 ··· 1876 2131 return 1877 2132 } 1878 2133 1879 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2134 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1880 2135 Repo: user.Did, 1881 2136 Writes: writes, 1882 2137 }) ··· 1897 2152 return 1898 2153 } 1899 2154 1900 - pull, ok := r.Context().Value("pull").(*db.Pull) 2155 + pull, ok := r.Context().Value("pull").(*models.Pull) 1901 2156 if !ok { 1902 2157 log.Println("failed to get pull") 1903 2158 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 1904 2159 return 1905 2160 } 1906 2161 1907 - var pullsToMerge db.Stack 2162 + var pullsToMerge models.Stack 1908 2163 pullsToMerge = append(pullsToMerge, pull) 1909 2164 if pull.IsStacked() { 1910 - stack, ok := r.Context().Value("stack").(db.Stack) 2165 + stack, ok := r.Context().Value("stack").(models.Stack) 1911 2166 if !ok { 1912 2167 log.Println("failed to get stack") 1913 2168 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") ··· 1997 2252 return 1998 2253 } 1999 2254 2255 + // notify about the pull merge 2256 + for _, p := range pullsToMerge { 2257 + s.notifier.NewPullMerged(r.Context(), p) 2258 + } 2259 + 2000 2260 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2001 2261 } 2002 2262 ··· 2009 2269 return 2010 2270 } 2011 2271 2012 - pull, ok := r.Context().Value("pull").(*db.Pull) 2272 + pull, ok := r.Context().Value("pull").(*models.Pull) 2013 2273 if !ok { 2014 2274 log.Println("failed to get pull") 2015 2275 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2037 2297 } 2038 2298 defer tx.Rollback() 2039 2299 2040 - var pullsToClose []*db.Pull 2300 + var pullsToClose []*models.Pull 2041 2301 pullsToClose = append(pullsToClose, pull) 2042 2302 2043 2303 // if this PR is stacked, then we want to close all PRs below this one on the stack 2044 2304 if pull.IsStacked() { 2045 - stack := r.Context().Value("stack").(db.Stack) 2305 + stack := r.Context().Value("stack").(models.Stack) 2046 2306 subStack := stack.StrictlyBelow(pull) 2047 2307 pullsToClose = append(pullsToClose, subStack...) 2048 2308 } ··· 2064 2324 return 2065 2325 } 2066 2326 2327 + for _, p := range pullsToClose { 2328 + s.notifier.NewPullClosed(r.Context(), p) 2329 + } 2330 + 2067 2331 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2068 2332 } 2069 2333 ··· 2077 2341 return 2078 2342 } 2079 2343 2080 - pull, ok := r.Context().Value("pull").(*db.Pull) 2344 + pull, ok := r.Context().Value("pull").(*models.Pull) 2081 2345 if !ok { 2082 2346 log.Println("failed to get pull") 2083 2347 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2105 2369 } 2106 2370 defer tx.Rollback() 2107 2371 2108 - var pullsToReopen []*db.Pull 2372 + var pullsToReopen []*models.Pull 2109 2373 pullsToReopen = append(pullsToReopen, pull) 2110 2374 2111 2375 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2112 2376 if pull.IsStacked() { 2113 - stack := r.Context().Value("stack").(db.Stack) 2377 + stack := r.Context().Value("stack").(models.Stack) 2114 2378 subStack := stack.StrictlyAbove(pull) 2115 2379 pullsToReopen = append(pullsToReopen, subStack...) 2116 2380 } ··· 2135 2399 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2136 2400 } 2137 2401 2138 - func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { 2402 + func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2139 2403 formatPatches, err := patchutil.ExtractPatches(patch) 2140 2404 if err != nil { 2141 2405 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2147 2411 } 2148 2412 2149 2413 // the stack is identified by a UUID 2150 - var stack db.Stack 2414 + var stack models.Stack 2151 2415 parentChangeId := "" 2152 2416 for _, fp := range formatPatches { 2153 2417 // all patches must have a jj change-id ··· 2160 2424 body := fp.Body 2161 2425 rkey := tid.TID() 2162 2426 2163 - initialSubmission := db.PullSubmission{ 2427 + initialSubmission := models.PullSubmission{ 2164 2428 Patch: fp.Raw, 2165 2429 SourceRev: fp.SHA, 2166 2430 } 2167 - pull := db.Pull{ 2431 + pull := models.Pull{ 2168 2432 Title: title, 2169 2433 Body: body, 2170 2434 TargetBranch: targetBranch, 2171 2435 OwnerDid: user.Did, 2172 2436 RepoAt: f.RepoAt(), 2173 2437 Rkey: rkey, 2174 - Submissions: []*db.PullSubmission{ 2438 + Submissions: []*models.PullSubmission{ 2175 2439 &initialSubmission, 2176 2440 }, 2177 2441 PullSource: pullSource,
+1 -1
appview/pulls/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (s *Pulls) Router(mw *middleware.Middleware) http.Handler {
+78 -32
appview/repo/artifact.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "context" 5 + "encoding/json" 4 6 "fmt" 7 + "io" 5 8 "log" 6 9 "net/http" 7 10 "net/url" 8 11 "time" 9 12 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pages" 17 + "tangled.org/core/appview/reporesolver" 18 + "tangled.org/core/appview/xrpcclient" 19 + "tangled.org/core/tid" 20 + "tangled.org/core/types" 21 + 10 22 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 23 lexutil "github.com/bluesky-social/indigo/lex/util" 24 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 12 25 "github.com/dustin/go-humanize" 13 26 "github.com/go-chi/chi/v5" 14 27 "github.com/go-git/go-git/v5/plumbing" 15 28 "github.com/ipfs/go-cid" 16 - "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview/db" 18 - "tangled.sh/tangled.sh/core/appview/pages" 19 - "tangled.sh/tangled.sh/core/appview/reporesolver" 20 - "tangled.sh/tangled.sh/core/knotclient" 21 - "tangled.sh/tangled.sh/core/tid" 22 - "tangled.sh/tangled.sh/core/types" 23 29 ) 24 30 25 31 // TODO: proper statuses here on early exit ··· 33 39 return 34 40 } 35 41 36 - tag, err := rp.resolveTag(f, tagParam) 42 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 37 43 if err != nil { 38 44 log.Println("failed to resolve tag", err) 39 45 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 55 61 return 56 62 } 57 63 58 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 59 65 if err != nil { 60 66 log.Println("failed to upload blob", err) 61 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 67 73 rkey := tid.TID() 68 74 createdAt := time.Now() 69 75 70 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 71 77 Collection: tangled.RepoArtifactNSID, 72 78 Repo: user.Did, 73 79 Rkey: rkey, ··· 97 103 } 98 104 defer tx.Rollback() 99 105 100 - artifact := db.Artifact{ 106 + artifact := models.Artifact{ 101 107 Did: user.Did, 102 108 Rkey: rkey, 103 109 RepoAt: f.RepoAt(), ··· 130 136 }) 131 137 } 132 138 133 - // TODO: proper statuses here on early exit 134 139 func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 135 - tagParam := chi.URLParam(r, "tag") 136 - filename := chi.URLParam(r, "file") 137 140 f, err := rp.repoResolver.Resolve(r) 138 141 if err != nil { 139 142 log.Println("failed to get repo and knot", err) 143 + http.Error(w, "failed to resolve repo", http.StatusInternalServerError) 140 144 return 141 145 } 142 146 143 - tag, err := rp.resolveTag(f, tagParam) 147 + tagParam := chi.URLParam(r, "tag") 148 + filename := chi.URLParam(r, "file") 149 + 150 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 144 151 if err != nil { 145 152 log.Println("failed to resolve tag", err) 146 153 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") 147 154 return 148 155 } 149 156 150 - client, err := rp.oauth.AuthorizedClient(r) 151 - if err != nil { 152 - log.Println("failed to get authorized client", err) 153 - return 154 - } 155 - 156 157 artifacts, err := db.GetArtifact( 157 158 rp.db, 158 159 db.FilterEq("repo_at", f.RepoAt()), ··· 161 162 ) 162 163 if err != nil { 163 164 log.Println("failed to get artifacts", err) 165 + http.Error(w, "failed to get artifact", http.StatusInternalServerError) 164 166 return 165 167 } 168 + 166 169 if len(artifacts) != 1 { 167 - log.Printf("too many or too little artifacts found") 170 + log.Printf("too many or too few artifacts found") 171 + http.Error(w, "artifact not found", http.StatusNotFound) 168 172 return 169 173 } 170 174 171 175 artifact := artifacts[0] 172 176 173 - getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did) 177 + ownerPds := f.OwnerId.PDSEndpoint() 178 + url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 179 + q := url.Query() 180 + q.Set("cid", artifact.BlobCid.String()) 181 + q.Set("did", artifact.Did) 182 + url.RawQuery = q.Encode() 183 + 184 + req, err := http.NewRequest(http.MethodGet, url.String(), nil) 174 185 if err != nil { 175 - log.Println("failed to get blob from pds", err) 186 + log.Println("failed to create request", err) 187 + http.Error(w, "failed to create request", http.StatusInternalServerError) 176 188 return 177 189 } 190 + req.Header.Set("Content-Type", "application/json") 178 191 179 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 180 - w.Write(getBlobResp) 192 + resp, err := http.DefaultClient.Do(req) 193 + if err != nil { 194 + log.Println("failed to make request", err) 195 + http.Error(w, "failed to make request to PDS", http.StatusInternalServerError) 196 + return 197 + } 198 + defer resp.Body.Close() 199 + 200 + // copy status code and relevant headers from upstream response 201 + w.WriteHeader(resp.StatusCode) 202 + for key, values := range resp.Header { 203 + for _, v := range values { 204 + w.Header().Add(key, v) 205 + } 206 + } 207 + 208 + // stream the body directly to the client 209 + if _, err := io.Copy(w, resp.Body); err != nil { 210 + log.Println("error streaming response to client:", err) 211 + } 181 212 } 182 213 183 214 // TODO: proper statuses here on early exit ··· 219 250 return 220 251 } 221 252 222 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 223 254 Collection: tangled.RepoArtifactNSID, 224 255 Repo: user.Did, 225 256 Rkey: artifact.Rkey, ··· 259 290 w.Write([]byte{}) 260 291 } 261 292 262 - func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 293 + func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 263 294 tagParam, err := url.QueryUnescape(tagParam) 264 295 if err != nil { 265 296 return nil, err 266 297 } 267 298 268 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 299 + scheme := "http" 300 + if !rp.config.Core.Dev { 301 + scheme = "https" 302 + } 303 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 304 + xrpcc := &indigoxrpc.Client{ 305 + Host: host, 306 + } 307 + 308 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 309 + xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 269 310 if err != nil { 311 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 312 + log.Println("failed to call XRPC repo.tags", xrpcerr) 313 + return nil, xrpcerr 314 + } 315 + log.Println("failed to reach knotserver", err) 270 316 return nil, err 271 317 } 272 318 273 - result, err := us.Tags(f.OwnerDid(), f.Name) 274 - if err != nil { 275 - log.Println("failed to reach knotserver", err) 319 + var result types.RepoTagsResponse 320 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 321 + log.Println("failed to decode XRPC tags response", err) 276 322 return nil, err 277 323 } 278 324
+16 -10
appview/repo/feed.go
··· 8 8 "slices" 9 9 "time" 10 10 11 - "tangled.sh/tangled.sh/core/appview/db" 12 - "tangled.sh/tangled.sh/core/appview/reporesolver" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pagination" 14 + "tangled.org/core/appview/reporesolver" 13 15 14 16 "github.com/bluesky-social/indigo/atproto/syntax" 15 17 "github.com/gorilla/feeds" ··· 23 25 return nil, err 24 26 } 25 27 26 - issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 28 + issues, err := db.GetIssuesPaginated( 29 + rp.db, 30 + pagination.Page{Limit: feedLimitPerType}, 31 + db.FilterEq("repo_at", f.RepoAt()), 32 + ) 27 33 if err != nil { 28 34 return nil, err 29 35 } ··· 65 71 return feed, nil 66 72 } 67 73 68 - func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 74 + func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 69 75 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 70 76 if err != nil { 71 77 return nil, err ··· 103 109 return items, nil 104 110 } 105 111 106 - func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 107 - owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid) 112 + func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 113 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 108 114 if err != nil { 109 115 return nil, err 110 116 } ··· 123 129 }, nil 124 130 } 125 131 126 - func (rp *Repo) getPullState(pull *db.Pull) string { 127 - if pull.State == db.PullOpen { 132 + func (rp *Repo) getPullState(pull *models.Pull) string { 133 + if pull.State == models.PullOpen { 128 134 return "opened" 129 135 } 130 136 return pull.State.String() 131 137 } 132 138 133 - func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 139 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string { 134 140 base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 135 141 136 - if pull.State == db.PullMerged { 142 + if pull.State == models.PullMerged { 137 143 return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 138 144 } 139 145
+207 -27
appview/repo/index.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "errors" 5 + "fmt" 4 6 "log" 5 7 "net/http" 8 + "net/url" 6 9 "slices" 7 10 "sort" 8 11 "strings" 12 + "sync" 13 + "time" 9 14 10 - "tangled.sh/tangled.sh/core/appview/commitverify" 11 - "tangled.sh/tangled.sh/core/appview/db" 12 - "tangled.sh/tangled.sh/core/appview/pages" 13 - "tangled.sh/tangled.sh/core/appview/reporesolver" 14 - "tangled.sh/tangled.sh/core/knotclient" 15 - "tangled.sh/tangled.sh/core/types" 15 + "context" 16 + "encoding/json" 17 + 18 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/go-git/go-git/v5/plumbing" 20 + "tangled.org/core/api/tangled" 21 + "tangled.org/core/appview/commitverify" 22 + "tangled.org/core/appview/db" 23 + "tangled.org/core/appview/models" 24 + "tangled.org/core/appview/pages" 25 + "tangled.org/core/appview/reporesolver" 26 + "tangled.org/core/appview/xrpcclient" 27 + "tangled.org/core/types" 16 28 17 29 "github.com/go-chi/chi/v5" 18 30 "github.com/go-enry/go-enry/v2" ··· 20 32 21 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 22 34 ref := chi.URLParam(r, "ref") 35 + ref, _ = url.PathUnescape(ref) 23 36 24 37 f, err := rp.repoResolver.Resolve(r) 25 38 if err != nil { ··· 27 40 return 28 41 } 29 42 30 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 31 - if err != nil { 32 - log.Printf("failed to create unsigned client for %s", f.Knot) 33 - rp.pages.Error503(w) 34 - return 43 + scheme := "http" 44 + if !rp.config.Core.Dev { 45 + scheme = "https" 46 + } 47 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 + xrpcc := &indigoxrpc.Client{ 49 + Host: host, 35 50 } 36 51 37 - result, err := us.Index(f.OwnerDid(), f.Name, ref) 38 - if err != nil { 52 + user := rp.oauth.GetUser(r) 53 + repoInfo := f.RepoInfo(user) 54 + 55 + // Build index response from multiple XRPC calls 56 + result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 57 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 58 + if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 59 + log.Println("failed to call XRPC repo.index", err) 60 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 61 + LoggedInUser: user, 62 + NeedsKnotUpgrade: true, 63 + RepoInfo: repoInfo, 64 + }) 65 + return 66 + } 67 + 39 68 rp.pages.Error503(w) 40 - log.Println("failed to reach knotserver", err) 69 + log.Println("failed to build index response", err) 41 70 return 42 71 } 43 72 ··· 98 127 log.Println(err) 99 128 } 100 129 101 - user := rp.oauth.GetUser(r) 102 - repoInfo := f.RepoInfo(user) 103 - 104 130 // TODO: a bit dirty 105 - languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 131 + languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 106 132 if err != nil { 107 133 log.Printf("failed to compute language percentages: %s", err) 108 134 // non-fatal ··· 135 161 } 136 162 137 163 func (rp *Repo) getLanguageInfo( 164 + ctx context.Context, 138 165 f *reporesolver.ResolvedRepo, 139 - us *knotclient.UnsignedClient, 166 + xrpcc *indigoxrpc.Client, 140 167 currentRef string, 141 168 isDefaultRef bool, 142 169 ) ([]types.RepoLanguageDetails, error) { ··· 148 175 ) 149 176 150 177 if err != nil || langs == nil { 151 - // non-fatal, fetch langs from ks 152 - ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 178 + // non-fatal, fetch langs from ks via XRPC 179 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 180 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 153 181 if err != nil { 182 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 183 + log.Println("failed to call XRPC repo.languages", xrpcerr) 184 + return nil, xrpcerr 185 + } 154 186 return nil, err 155 187 } 156 - if ls == nil { 188 + 189 + if ls == nil || ls.Languages == nil { 157 190 return nil, nil 158 191 } 159 192 160 - for l, s := range ls.Languages { 161 - langs = append(langs, db.RepoLanguage{ 193 + for _, lang := range ls.Languages { 194 + langs = append(langs, models.RepoLanguage{ 162 195 RepoAt: f.RepoAt(), 163 196 Ref: currentRef, 164 197 IsDefaultRef: isDefaultRef, 165 - Language: l, 166 - Bytes: s, 198 + Language: lang.Name, 199 + Bytes: lang.Size, 167 200 }) 168 201 } 169 202 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 208 + 170 209 // update appview's cache 171 - err = db.InsertRepoLanguages(rp.db, langs) 210 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 172 211 if err != nil { 173 212 // non-fatal 174 213 log.Println("failed to cache lang results", err) 214 + } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 175 219 } 176 220 } 177 221 ··· 206 250 207 251 return languageStats, nil 208 252 } 253 + 254 + // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 255 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 256 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 257 + 258 + // first get branches to determine the ref if not specified 259 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 260 + if err != nil { 261 + return nil, fmt.Errorf("failed to call repoBranches: %w", err) 262 + } 263 + 264 + var branchesResp types.RepoBranchesResponse 265 + if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil { 266 + return nil, fmt.Errorf("failed to unmarshal branches response: %w", err) 267 + } 268 + 269 + // if no ref specified, use default branch or first available 270 + if ref == "" { 271 + for _, branch := range branchesResp.Branches { 272 + if branch.IsDefault { 273 + ref = branch.Name 274 + break 275 + } 276 + } 277 + } 278 + 279 + // if ref is still empty, this means the default branch is not set 280 + if ref == "" { 281 + return &types.RepoIndexResponse{ 282 + IsEmpty: true, 283 + Branches: branchesResp.Branches, 284 + }, nil 285 + } 286 + 287 + // now run the remaining queries in parallel 288 + var wg sync.WaitGroup 289 + var errs error 290 + 291 + var ( 292 + tagsResp types.RepoTagsResponse 293 + treeResp *tangled.RepoTree_Output 294 + logResp types.RepoLogResponse 295 + readmeContent string 296 + readmeFileName string 297 + ) 298 + 299 + // tags 300 + wg.Add(1) 301 + go func() { 302 + defer wg.Done() 303 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 304 + if err != nil { 305 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 306 + return 307 + } 308 + 309 + if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 310 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoTags: %w", err)) 311 + } 312 + }() 313 + 314 + // tree/files 315 + wg.Add(1) 316 + go func() { 317 + defer wg.Done() 318 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 319 + if err != nil { 320 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 321 + return 322 + } 323 + treeResp = resp 324 + }() 325 + 326 + // commits 327 + wg.Add(1) 328 + go func() { 329 + defer wg.Done() 330 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 331 + if err != nil { 332 + errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 333 + return 334 + } 335 + 336 + if err := json.Unmarshal(logBytes, &logResp); err != nil { 337 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoLog: %w", err)) 338 + } 339 + }() 340 + 341 + wg.Wait() 342 + 343 + if errs != nil { 344 + return nil, errs 345 + } 346 + 347 + var files []types.NiceTree 348 + if treeResp != nil && treeResp.Files != nil { 349 + for _, file := range treeResp.Files { 350 + niceFile := types.NiceTree{ 351 + IsFile: file.Is_file, 352 + IsSubtree: file.Is_subtree, 353 + Name: file.Name, 354 + Mode: file.Mode, 355 + Size: file.Size, 356 + } 357 + if file.Last_commit != nil { 358 + when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 359 + niceFile.LastCommit = &types.LastCommitInfo{ 360 + Hash: plumbing.NewHash(file.Last_commit.Hash), 361 + Message: file.Last_commit.Message, 362 + When: when, 363 + } 364 + } 365 + files = append(files, niceFile) 366 + } 367 + } 368 + 369 + if treeResp != nil && treeResp.Readme != nil { 370 + readmeFileName = treeResp.Readme.Filename 371 + readmeContent = treeResp.Readme.Contents 372 + } 373 + 374 + result := &types.RepoIndexResponse{ 375 + IsEmpty: false, 376 + Ref: ref, 377 + Readme: readmeContent, 378 + ReadmeFileName: readmeFileName, 379 + Commits: logResp.Commits, 380 + Description: logResp.Description, 381 + Files: files, 382 + Branches: branchesResp.Branches, 383 + Tags: tagsResp.Tags, 384 + TotalCommits: logResp.Total, 385 + } 386 + 387 + return result, nil 388 + }
+500
appview/repo/ogcard/card.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 + // SPDX-License-Identifier: MIT 4 + 5 + package ogcard 6 + 7 + import ( 8 + "bytes" 9 + "fmt" 10 + "image" 11 + "image/color" 12 + "io" 13 + "log" 14 + "math" 15 + "net/http" 16 + "strings" 17 + "sync" 18 + "time" 19 + 20 + "github.com/goki/freetype" 21 + "github.com/goki/freetype/truetype" 22 + "github.com/srwiley/oksvg" 23 + "github.com/srwiley/rasterx" 24 + "golang.org/x/image/draw" 25 + "golang.org/x/image/font" 26 + "tangled.org/core/appview/pages" 27 + 28 + _ "golang.org/x/image/webp" // for processing webp images 29 + ) 30 + 31 + type Card struct { 32 + Img *image.RGBA 33 + Font *truetype.Font 34 + Margin int 35 + Width int 36 + Height int 37 + } 38 + 39 + var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 + interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 + if err != nil { 42 + return nil, err 43 + } 44 + return truetype.Parse(interVar) 45 + }) 46 + 47 + // DefaultSize returns the default size for a card 48 + func DefaultSize() (int, int) { 49 + return 1200, 630 50 + } 51 + 52 + // NewCard creates a new card with the given dimensions in pixels 53 + func NewCard(width, height int) (*Card, error) { 54 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 + draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 + 57 + font, err := fontCache() 58 + if err != nil { 59 + return nil, err 60 + } 61 + 62 + return &Card{ 63 + Img: img, 64 + Font: font, 65 + Margin: 0, 66 + Width: width, 67 + Height: height, 68 + }, nil 69 + } 70 + 71 + // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 + // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 + func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 + bounds := c.Img.Bounds() 75 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 76 + if vertical { 77 + mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 + subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 + subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 + return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 + &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 + } 83 + mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 + subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 + subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 + return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 + &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 + } 89 + 90 + // SetMargin sets the margins for the card 91 + func (c *Card) SetMargin(margin int) { 92 + c.Margin = margin 93 + } 94 + 95 + type ( 96 + VAlign int64 97 + HAlign int64 98 + ) 99 + 100 + const ( 101 + Top VAlign = iota 102 + Middle 103 + Bottom 104 + ) 105 + 106 + const ( 107 + Left HAlign = iota 108 + Center 109 + Right 110 + ) 111 + 112 + // DrawText draws text within the card, respecting margins and alignment 113 + func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 + ft := freetype.NewContext() 115 + ft.SetDPI(72) 116 + ft.SetFont(c.Font) 117 + ft.SetFontSize(sizePt) 118 + ft.SetClip(c.Img.Bounds()) 119 + ft.SetDst(c.Img) 120 + ft.SetSrc(image.NewUniform(textColor)) 121 + 122 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 + fontHeight := ft.PointToFixed(sizePt).Ceil() 124 + 125 + bounds := c.Img.Bounds() 126 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 127 + boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 + // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 + 130 + // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 131 + // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 + // knowing the total height, which is related to how many lines we'll have. 133 + lines := make([]string, 0) 134 + textWords := strings.Split(text, " ") 135 + currentLine := "" 136 + heightTotal := 0 137 + 138 + for { 139 + if len(textWords) == 0 { 140 + // Ran out of words. 141 + if currentLine != "" { 142 + heightTotal += fontHeight 143 + lines = append(lines, currentLine) 144 + } 145 + break 146 + } 147 + 148 + nextWord := textWords[0] 149 + proposedLine := currentLine 150 + if proposedLine != "" { 151 + proposedLine += " " 152 + } 153 + proposedLine += nextWord 154 + 155 + proposedLineWidth := font.MeasureString(face, proposedLine) 156 + if proposedLineWidth.Ceil() > boxWidth { 157 + // no, proposed line is too big; we'll use the last "currentLine" 158 + heightTotal += fontHeight 159 + if currentLine != "" { 160 + lines = append(lines, currentLine) 161 + currentLine = "" 162 + // leave nextWord in textWords and keep going 163 + } else { 164 + // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 + // regardless as a line by itself. It will be clipped by the drawing routine. 166 + lines = append(lines, nextWord) 167 + textWords = textWords[1:] 168 + } 169 + } else { 170 + // yes, it will fit 171 + currentLine = proposedLine 172 + textWords = textWords[1:] 173 + } 174 + } 175 + 176 + textY := 0 177 + switch valign { 178 + case Top: 179 + textY = fontHeight 180 + case Bottom: 181 + textY = boxHeight - heightTotal + fontHeight 182 + case Middle: 183 + textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 + } 185 + 186 + for _, line := range lines { 187 + lineWidth := font.MeasureString(face, line) 188 + 189 + textX := 0 190 + switch halign { 191 + case Left: 192 + textX = 0 193 + case Right: 194 + textX = boxWidth - lineWidth.Ceil() 195 + case Center: 196 + textX = (boxWidth - lineWidth.Ceil()) / 2 197 + } 198 + 199 + pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 + _, err := ft.DrawString(line, pt) 201 + if err != nil { 202 + return nil, err 203 + } 204 + 205 + textY += fontHeight 206 + } 207 + 208 + return lines, nil 209 + } 210 + 211 + // DrawTextAt draws text at a specific position with the given alignment 212 + func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 + _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 + return err 215 + } 216 + 217 + // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 + func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 + ft := freetype.NewContext() 220 + ft.SetDPI(72) 221 + ft.SetFont(c.Font) 222 + ft.SetFontSize(sizePt) 223 + ft.SetClip(c.Img.Bounds()) 224 + ft.SetDst(c.Img) 225 + ft.SetSrc(image.NewUniform(textColor)) 226 + 227 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 + fontHeight := ft.PointToFixed(sizePt).Ceil() 229 + lineWidth := font.MeasureString(face, text) 230 + textWidth := lineWidth.Ceil() 231 + 232 + // Adjust position based on alignment 233 + adjustedX := x 234 + adjustedY := y 235 + 236 + switch halign { 237 + case Left: 238 + // x is already at the left position 239 + case Right: 240 + adjustedX = x - textWidth 241 + case Center: 242 + adjustedX = x - textWidth/2 243 + } 244 + 245 + switch valign { 246 + case Top: 247 + adjustedY = y + fontHeight 248 + case Bottom: 249 + adjustedY = y 250 + case Middle: 251 + adjustedY = y + fontHeight/2 252 + } 253 + 254 + pt := freetype.Pt(adjustedX, adjustedY) 255 + _, err := ft.DrawString(text, pt) 256 + return textWidth, err 257 + } 258 + 259 + // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 + func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 + // Draw the text multiple times with slight offsets to create bold effect 262 + offsets := []struct{ dx, dy int }{ 263 + {0, 0}, // original 264 + {1, 0}, // right 265 + {0, 1}, // down 266 + {1, 1}, // diagonal 267 + } 268 + 269 + var width int 270 + for _, offset := range offsets { 271 + w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 + if err != nil { 273 + return 0, err 274 + } 275 + if width == 0 { 276 + width = w 277 + } 278 + } 279 + return width, nil 280 + } 281 + 282 + // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 + func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 + svgData, err := pages.Files.ReadFile(svgPath) 285 + if err != nil { 286 + return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 + } 288 + 289 + // Convert color to hex string for SVG 290 + rgba, isRGBA := iconColor.(color.RGBA) 291 + if !isRGBA { 292 + r, g, b, a := iconColor.RGBA() 293 + rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 + } 295 + colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 + 297 + // Replace currentColor with our desired color in the SVG 298 + svgString := string(svgData) 299 + svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 + 301 + // Make the stroke thicker 302 + svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 + 304 + // Parse SVG 305 + icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 + if err != nil { 307 + return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 + } 309 + 310 + // Set the icon size 311 + w, h := float64(size), float64(size) 312 + icon.SetTarget(0, 0, w, h) 313 + 314 + // Create a temporary RGBA image for the icon 315 + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 + 317 + // Create scanner and rasterizer 318 + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 + raster := rasterx.NewDasher(size, size, scanner) 320 + 321 + // Draw the icon 322 + icon.Draw(raster, 1.0) 323 + 324 + // Draw the icon onto the card at the specified position 325 + bounds := c.Img.Bounds() 326 + destRect := image.Rect(x, y, x+size, y+size) 327 + 328 + // Make sure we don't draw outside the card bounds 329 + if destRect.Max.X > bounds.Max.X { 330 + destRect.Max.X = bounds.Max.X 331 + } 332 + if destRect.Max.Y > bounds.Max.Y { 333 + destRect.Max.Y = bounds.Max.Y 334 + } 335 + 336 + draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 + 338 + return nil 339 + } 340 + 341 + // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 342 + func (c *Card) DrawImage(img image.Image) { 343 + bounds := c.Img.Bounds() 344 + targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 345 + srcBounds := img.Bounds() 346 + srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 + targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 + 349 + var scale float64 350 + if srcAspect > targetAspect { 351 + // Image is wider than target, scale by width 352 + scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 + } else { 354 + // Image is taller or equal, scale by height 355 + scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 + } 357 + 358 + newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 + newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 + 361 + // Center the image within the target rectangle 362 + offsetX := (targetRect.Dx() - newWidth) / 2 363 + offsetY := (targetRect.Dy() - newHeight) / 2 364 + 365 + scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 + draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 + } 368 + 369 + func fallbackImage() image.Image { 370 + // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 + img.Set(0, 0, color.White) 373 + return img 374 + } 375 + 376 + // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 + func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 378 + // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 379 + // this rendering process to be slowed down 380 + client := &http.Client{ 381 + Timeout: 1 * time.Second, // 1 second timeout 382 + } 383 + 384 + resp, err := client.Get(url) 385 + if err != nil { 386 + log.Printf("error when fetching external image from %s: %v", url, err) 387 + return nil, false 388 + } 389 + defer resp.Body.Close() 390 + 391 + if resp.StatusCode != http.StatusOK { 392 + log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 + return nil, false 394 + } 395 + 396 + contentType := resp.Header.Get("Content-Type") 397 + // Support content types are in-sync with the allowed custom avatar file types 398 + if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 399 + log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 400 + return nil, false 401 + } 402 + 403 + body := resp.Body 404 + bodyBytes, err := io.ReadAll(body) 405 + if err != nil { 406 + log.Printf("error when fetching external image from %s: %v", url, err) 407 + return nil, false 408 + } 409 + 410 + bodyBuffer := bytes.NewReader(bodyBytes) 411 + _, imgType, err := image.DecodeConfig(bodyBuffer) 412 + if err != nil { 413 + log.Printf("error when decoding external image from %s: %v", url, err) 414 + return nil, false 415 + } 416 + 417 + // Verify that we have a match between actual data understood in the image body and the reported Content-Type 418 + if (contentType == "image/png" && imgType != "png") || 419 + (contentType == "image/jpeg" && imgType != "jpeg") || 420 + (contentType == "image/gif" && imgType != "gif") || 421 + (contentType == "image/webp" && imgType != "webp") { 422 + log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 423 + return nil, false 424 + } 425 + 426 + _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 427 + if err != nil { 428 + log.Printf("error w/ bodyBuffer.Seek") 429 + return nil, false 430 + } 431 + img, _, err := image.Decode(bodyBuffer) 432 + if err != nil { 433 + log.Printf("error when decoding external image from %s: %v", url, err) 434 + return nil, false 435 + } 436 + 437 + return img, true 438 + } 439 + 440 + func (c *Card) DrawExternalImage(url string) { 441 + image, ok := c.fetchExternalImage(url) 442 + if !ok { 443 + image = fallbackImage() 444 + } 445 + c.DrawImage(image) 446 + } 447 + 448 + // DrawCircularExternalImage draws an external image as a circle at the specified position 449 + func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 450 + img, ok := c.fetchExternalImage(url) 451 + if !ok { 452 + img = fallbackImage() 453 + } 454 + 455 + // Create a circular mask 456 + circle := image.NewRGBA(image.Rect(0, 0, size, size)) 457 + center := size / 2 458 + radius := float64(size / 2) 459 + 460 + // Scale the source image to fit the circle 461 + srcBounds := img.Bounds() 462 + scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 463 + draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 464 + 465 + // Draw the image with circular clipping 466 + for cy := 0; cy < size; cy++ { 467 + for cx := 0; cx < size; cx++ { 468 + // Calculate distance from center 469 + dx := float64(cx - center) 470 + dy := float64(cy - center) 471 + distance := math.Sqrt(dx*dx + dy*dy) 472 + 473 + // Only draw pixels within the circle 474 + if distance <= radius { 475 + circle.Set(cx, cy, scaledImg.At(cx, cy)) 476 + } 477 + } 478 + } 479 + 480 + // Draw the circle onto the card 481 + bounds := c.Img.Bounds() 482 + destRect := image.Rect(x, y, x+size, y+size) 483 + 484 + // Make sure we don't draw outside the card bounds 485 + if destRect.Max.X > bounds.Max.X { 486 + destRect.Max.X = bounds.Max.X 487 + } 488 + if destRect.Max.Y > bounds.Max.Y { 489 + destRect.Max.Y = bounds.Max.Y 490 + } 491 + 492 + draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 493 + 494 + return nil 495 + } 496 + 497 + // DrawRect draws a rect with the given color 498 + func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 499 + draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 500 + }
+402
appview/repo/opengraph.go
··· 1 + package repo 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/hex" 7 + "fmt" 8 + "image/color" 9 + "image/png" 10 + "log" 11 + "net/http" 12 + "sort" 13 + "strings" 14 + 15 + "github.com/go-enry/go-enry/v2" 16 + "tangled.org/core/appview/db" 17 + "tangled.org/core/appview/models" 18 + "tangled.org/core/appview/repo/ogcard" 19 + "tangled.org/core/types" 20 + ) 21 + 22 + func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) { 23 + width, height := ogcard.DefaultSize() 24 + mainCard, err := ogcard.NewCard(width, height) 25 + if err != nil { 26 + return nil, err 27 + } 28 + 29 + // Split: content area (75%) and language bar + icons (25%) 30 + contentCard, bottomArea := mainCard.Split(false, 75) 31 + 32 + // Add padding to content 33 + contentCard.SetMargin(50) 34 + 35 + // Split content horizontally: main content (80%) and avatar area (20%) 36 + mainContent, avatarArea := contentCard.Split(true, 80) 37 + 38 + // Use main content area for both repo name and description to allow dynamic wrapping. 39 + mainContent.SetMargin(10) 40 + 41 + var ownerHandle string 42 + owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 43 + if err != nil { 44 + ownerHandle = repo.Did 45 + } else { 46 + ownerHandle = "@" + owner.Handle.String() 47 + } 48 + 49 + bounds := mainContent.Img.Bounds() 50 + startX := bounds.Min.X + mainContent.Margin 51 + startY := bounds.Min.Y + mainContent.Margin 52 + currentX := startX 53 + currentY := startY 54 + lineHeight := 64 // Font size 54 + padding 55 + textColor := color.RGBA{88, 96, 105, 255} 56 + 57 + // Draw owner handle 58 + ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 59 + if err != nil { 60 + return nil, err 61 + } 62 + currentX += ownerWidth 63 + 64 + // Draw separator 65 + sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 66 + if err != nil { 67 + return nil, err 68 + } 69 + currentX += sepWidth 70 + 71 + words := strings.Fields(repo.Name) 72 + spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 73 + if spaceWidth == 0 { 74 + spaceWidth = 15 75 + } 76 + 77 + for _, word := range words { 78 + // estimate bold width by measuring regular width and adding a multiplier 79 + regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 80 + estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text 81 + 82 + if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) { 83 + currentX = startX 84 + currentY += lineHeight 85 + } 86 + 87 + _, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left) 88 + if err != nil { 89 + return nil, err 90 + } 91 + currentX += estimatedBoldWidth + spaceWidth 92 + } 93 + 94 + // update Y position for the description 95 + currentY += lineHeight 96 + 97 + // draw description 98 + if currentY < bounds.Max.Y-mainContent.Margin { 99 + totalHeight := float64(bounds.Dy()) 100 + repoNameHeight := float64(currentY - bounds.Min.Y) 101 + 102 + if totalHeight > 0 && repoNameHeight < totalHeight { 103 + repoNamePercent := (repoNameHeight / totalHeight) * 100 104 + if repoNamePercent < 95 { // Ensure there's space left for description 105 + _, descriptionCard := mainContent.Split(false, int(repoNamePercent)) 106 + descriptionCard.SetMargin(8) 107 + 108 + description := repo.Description 109 + if len(description) > 70 { 110 + description = description[:70] + "…" 111 + } 112 + 113 + _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) 114 + if err != nil { 115 + log.Printf("failed to draw description: %v", err) 116 + } 117 + } 118 + } 119 + } 120 + 121 + // Draw avatar circle on the right side 122 + avatarBounds := avatarArea.Img.Bounds() 123 + avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 124 + if avatarSize > 220 { 125 + avatarSize = 220 126 + } 127 + avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 128 + avatarY := avatarBounds.Min.Y + 20 129 + 130 + // Get avatar URL and draw it 131 + avatarURL := rp.pages.AvatarUrl(ownerHandle, "256") 132 + err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 133 + if err != nil { 134 + log.Printf("failed to draw avatar (non-fatal): %v", err) 135 + } 136 + 137 + // Split bottom area: icons area (65%) and language bar (35%) 138 + iconsArea, languageBarCard := bottomArea.Split(false, 75) 139 + 140 + // Split icons area: left side for stats (80%), right side for dolly (20%) 141 + statsArea, dollyArea := iconsArea.Split(true, 80) 142 + 143 + // Draw stats with icons in the stats area 144 + starsText := repo.RepoStats.StarCount 145 + issuesText := repo.RepoStats.IssueCount.Open 146 + pullRequestsText := repo.RepoStats.PullCount.Open 147 + 148 + iconColor := color.RGBA{88, 96, 105, 255} 149 + iconSize := 36 150 + textSize := 36.0 151 + 152 + // Position stats in the middle of the stats area 153 + statsBounds := statsArea.Img.Bounds() 154 + statsX := statsBounds.Min.X + 60 // left padding 155 + statsY := statsBounds.Min.Y 156 + currentX = statsX 157 + labelSize := 22.0 158 + // Draw star icon, count, and label 159 + // Align icon baseline with text baseline 160 + iconBaselineOffset := int(textSize) / 2 161 + err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 162 + if err != nil { 163 + log.Printf("failed to draw star icon: %v", err) 164 + } 165 + starIconX := currentX 166 + currentX += iconSize + 15 167 + 168 + starText := fmt.Sprintf("%d", starsText) 169 + err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 170 + if err != nil { 171 + log.Printf("failed to draw star text: %v", err) 172 + } 173 + starTextWidth := len(starText) * 20 174 + starGroupWidth := iconSize + 15 + starTextWidth 175 + 176 + // Draw "stars" label below and centered under the icon+text group 177 + labelY := statsY + iconSize + 15 178 + labelX := starIconX + starGroupWidth/2 179 + err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 180 + if err != nil { 181 + log.Printf("failed to draw stars label: %v", err) 182 + } 183 + 184 + currentX += starTextWidth + 50 185 + 186 + // Draw issues icon, count, and label 187 + issueStartX := currentX 188 + err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 189 + if err != nil { 190 + log.Printf("failed to draw circle-dot icon: %v", err) 191 + } 192 + currentX += iconSize + 15 193 + 194 + issueText := fmt.Sprintf("%d", issuesText) 195 + err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 196 + if err != nil { 197 + log.Printf("failed to draw issue text: %v", err) 198 + } 199 + issueTextWidth := len(issueText) * 20 200 + issueGroupWidth := iconSize + 15 + issueTextWidth 201 + 202 + // Draw "issues" label below and centered under the icon+text group 203 + labelX = issueStartX + issueGroupWidth/2 204 + err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 205 + if err != nil { 206 + log.Printf("failed to draw issues label: %v", err) 207 + } 208 + 209 + currentX += issueTextWidth + 50 210 + 211 + // Draw pull request icon, count, and label 212 + prStartX := currentX 213 + err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 214 + if err != nil { 215 + log.Printf("failed to draw git-pull-request icon: %v", err) 216 + } 217 + currentX += iconSize + 15 218 + 219 + prText := fmt.Sprintf("%d", pullRequestsText) 220 + err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 221 + if err != nil { 222 + log.Printf("failed to draw PR text: %v", err) 223 + } 224 + prTextWidth := len(prText) * 20 225 + prGroupWidth := iconSize + 15 + prTextWidth 226 + 227 + // Draw "pulls" label below and centered under the icon+text group 228 + labelX = prStartX + prGroupWidth/2 229 + err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 230 + if err != nil { 231 + log.Printf("failed to draw pulls label: %v", err) 232 + } 233 + 234 + dollyBounds := dollyArea.Img.Bounds() 235 + dollySize := 90 236 + dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 237 + dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 238 + dollyColor := color.RGBA{180, 180, 180, 255} // light gray 239 + err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 240 + if err != nil { 241 + log.Printf("dolly silhouette not available (this is ok): %v", err) 242 + } 243 + 244 + // Draw language bar at bottom 245 + err = drawLanguagesCard(languageBarCard, languageStats) 246 + if err != nil { 247 + log.Printf("failed to draw language bar: %v", err) 248 + return nil, err 249 + } 250 + 251 + return mainCard, nil 252 + } 253 + 254 + // hexToColor converts a hex color to a go color 255 + func hexToColor(colorStr string) (*color.RGBA, error) { 256 + colorStr = strings.TrimLeft(colorStr, "#") 257 + 258 + b, err := hex.DecodeString(colorStr) 259 + if err != nil { 260 + return nil, err 261 + } 262 + 263 + if len(b) < 3 { 264 + return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b)) 265 + } 266 + 267 + clr := color.RGBA{b[0], b[1], b[2], 255} 268 + 269 + return &clr, nil 270 + } 271 + 272 + func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error { 273 + bounds := card.Img.Bounds() 274 + cardWidth := bounds.Dx() 275 + 276 + if len(languageStats) == 0 { 277 + // Draw a light gray bar if no languages detected 278 + card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255}) 279 + return nil 280 + } 281 + 282 + // Limit to top 5 languages for the visual bar 283 + displayLanguages := languageStats 284 + if len(displayLanguages) > 5 { 285 + displayLanguages = displayLanguages[:5] 286 + } 287 + 288 + currentX := bounds.Min.X 289 + 290 + for _, lang := range displayLanguages { 291 + var langColor *color.RGBA 292 + var err error 293 + 294 + if lang.Color != "" { 295 + langColor, err = hexToColor(lang.Color) 296 + if err != nil { 297 + // Fallback to a default color 298 + langColor = &color.RGBA{149, 157, 165, 255} 299 + } 300 + } else { 301 + // Default color if no color specified 302 + langColor = &color.RGBA{149, 157, 165, 255} 303 + } 304 + 305 + langWidth := float32(cardWidth) * (lang.Percentage / 100) 306 + card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor) 307 + currentX += int(langWidth) 308 + } 309 + 310 + // Fill remaining space with the last color (if any gap due to rounding) 311 + if currentX < bounds.Max.X && len(displayLanguages) > 0 { 312 + lastLang := displayLanguages[len(displayLanguages)-1] 313 + var lastColor *color.RGBA 314 + var err error 315 + 316 + if lastLang.Color != "" { 317 + lastColor, err = hexToColor(lastLang.Color) 318 + if err != nil { 319 + lastColor = &color.RGBA{149, 157, 165, 255} 320 + } 321 + } else { 322 + lastColor = &color.RGBA{149, 157, 165, 255} 323 + } 324 + card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor) 325 + } 326 + 327 + return nil 328 + } 329 + 330 + func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 331 + f, err := rp.repoResolver.Resolve(r) 332 + if err != nil { 333 + log.Println("failed to get repo and knot", err) 334 + return 335 + } 336 + 337 + // Get language stats directly from database 338 + var languageStats []types.RepoLanguageDetails 339 + langs, err := db.GetRepoLanguages( 340 + rp.db, 341 + db.FilterEq("repo_at", f.RepoAt()), 342 + db.FilterEq("is_default_ref", 1), 343 + ) 344 + if err != nil { 345 + log.Printf("failed to get language stats from db: %v", err) 346 + // non-fatal, continue without language stats 347 + } else if len(langs) > 0 { 348 + var total int64 349 + for _, l := range langs { 350 + total += l.Bytes 351 + } 352 + 353 + for _, l := range langs { 354 + percentage := float32(l.Bytes) / float32(total) * 100 355 + color := enry.GetColor(l.Language) 356 + languageStats = append(languageStats, types.RepoLanguageDetails{ 357 + Name: l.Language, 358 + Percentage: percentage, 359 + Color: color, 360 + }) 361 + } 362 + 363 + sort.Slice(languageStats, func(i, j int) bool { 364 + if languageStats[i].Name == enry.OtherLanguage { 365 + return false 366 + } 367 + if languageStats[j].Name == enry.OtherLanguage { 368 + return true 369 + } 370 + if languageStats[i].Percentage != languageStats[j].Percentage { 371 + return languageStats[i].Percentage > languageStats[j].Percentage 372 + } 373 + return languageStats[i].Name < languageStats[j].Name 374 + }) 375 + } 376 + 377 + card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats) 378 + if err != nil { 379 + log.Println("failed to draw repo summary card", err) 380 + http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError) 381 + return 382 + } 383 + 384 + var imageBuffer bytes.Buffer 385 + err = png.Encode(&imageBuffer, card.Img) 386 + if err != nil { 387 + log.Println("failed to encode repo summary card", err) 388 + http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError) 389 + return 390 + } 391 + 392 + imageBytes := imageBuffer.Bytes() 393 + 394 + w.Header().Set("Content-Type", "image/png") 395 + w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 396 + w.WriteHeader(http.StatusOK) 397 + _, err = w.Write(imageBytes) 398 + if err != nil { 399 + log.Println("failed to write repo summary card", err) 400 + return 401 + } 402 + }
+1076 -249
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 19 20 + "tangled.org/core/api/tangled" 21 + "tangled.org/core/appview/commitverify" 22 + "tangled.org/core/appview/config" 23 + "tangled.org/core/appview/db" 24 + "tangled.org/core/appview/models" 25 + "tangled.org/core/appview/notify" 26 + "tangled.org/core/appview/oauth" 27 + "tangled.org/core/appview/pages" 28 + "tangled.org/core/appview/pages/markup" 29 + "tangled.org/core/appview/reporesolver" 30 + "tangled.org/core/appview/validator" 31 + xrpcclient "tangled.org/core/appview/xrpcclient" 32 + "tangled.org/core/eventconsumer" 33 + "tangled.org/core/idresolver" 34 + "tangled.org/core/patchutil" 35 + "tangled.org/core/rbac" 36 + "tangled.org/core/tid" 37 + "tangled.org/core/types" 38 + "tangled.org/core/xrpc/serviceauth" 39 + 20 40 comatproto "github.com/bluesky-social/indigo/api/atproto" 41 + atpclient "github.com/bluesky-social/indigo/atproto/client" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 21 43 lexutil "github.com/bluesky-social/indigo/lex/util" 22 - "tangled.sh/tangled.sh/core/api/tangled" 23 - "tangled.sh/tangled.sh/core/appview/commitverify" 24 - "tangled.sh/tangled.sh/core/appview/config" 25 - "tangled.sh/tangled.sh/core/appview/db" 26 - "tangled.sh/tangled.sh/core/appview/notify" 27 - "tangled.sh/tangled.sh/core/appview/oauth" 28 - "tangled.sh/tangled.sh/core/appview/pages" 29 - "tangled.sh/tangled.sh/core/appview/pages/markup" 30 - "tangled.sh/tangled.sh/core/appview/reporesolver" 31 - xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 - "tangled.sh/tangled.sh/core/eventconsumer" 33 - "tangled.sh/tangled.sh/core/idresolver" 34 - "tangled.sh/tangled.sh/core/knotclient" 35 - "tangled.sh/tangled.sh/core/patchutil" 36 - "tangled.sh/tangled.sh/core/rbac" 37 - "tangled.sh/tangled.sh/core/tid" 38 - "tangled.sh/tangled.sh/core/types" 39 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 40 - 44 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 41 45 securejoin "github.com/cyphar/filepath-securejoin" 42 46 "github.com/go-chi/chi/v5" 43 47 "github.com/go-git/go-git/v5/plumbing" 44 - 45 - "github.com/bluesky-social/indigo/atproto/syntax" 46 48 ) 47 49 48 50 type Repo struct { ··· 57 59 notifier notify.Notifier 58 60 logger *slog.Logger 59 61 serviceAuth *serviceauth.ServiceAuth 62 + validator *validator.Validator 60 63 } 61 64 62 65 func New( ··· 70 73 notifier notify.Notifier, 71 74 enforcer *rbac.Enforcer, 72 75 logger *slog.Logger, 76 + validator *validator.Validator, 73 77 ) *Repo { 74 78 return &Repo{oauth: oauth, 75 79 repoResolver: repoResolver, ··· 81 85 notifier: notifier, 82 86 enforcer: enforcer, 83 87 logger: logger, 88 + validator: validator, 84 89 } 85 90 } 86 91 87 92 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 - refParam := chi.URLParam(r, "ref") 93 + ref := chi.URLParam(r, "ref") 94 + ref, _ = url.PathUnescape(ref) 95 + 89 96 f, err := rp.repoResolver.Resolve(r) 90 97 if err != nil { 91 98 log.Println("failed to get repo and knot", err) 92 99 return 93 100 } 94 101 95 - var uri string 96 - if rp.config.Core.Dev { 97 - uri = "http" 98 - } else { 99 - uri = "https" 102 + scheme := "http" 103 + if !rp.config.Core.Dev { 104 + scheme = "https" 105 + } 106 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 107 + xrpcc := &indigoxrpc.Client{ 108 + Host: host, 109 + } 110 + 111 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 112 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 113 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 114 + log.Println("failed to call XRPC repo.archive", xrpcerr) 115 + rp.pages.Error503(w) 116 + return 100 117 } 101 - url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 118 + 119 + // Set headers for file download, just pass along whatever the knot specifies 120 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 121 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 122 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 123 + w.Header().Set("Content-Type", "application/gzip") 124 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 102 125 103 - http.Redirect(w, r, url, http.StatusFound) 126 + // Write the archive data directly 127 + w.Write(archiveBytes) 104 128 } 105 129 106 130 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { ··· 119 143 } 120 144 121 145 ref := chi.URLParam(r, "ref") 146 + ref, _ = url.PathUnescape(ref) 122 147 123 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 124 - if err != nil { 125 - log.Println("failed to create unsigned client", err) 148 + scheme := "http" 149 + if !rp.config.Core.Dev { 150 + scheme = "https" 151 + } 152 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 153 + xrpcc := &indigoxrpc.Client{ 154 + Host: host, 155 + } 156 + 157 + limit := int64(60) 158 + cursor := "" 159 + if page > 1 { 160 + // Convert page number to cursor (offset) 161 + offset := (page - 1) * int(limit) 162 + cursor = strconv.Itoa(offset) 163 + } 164 + 165 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 166 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 167 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 168 + log.Println("failed to call XRPC repo.log", xrpcerr) 169 + rp.pages.Error503(w) 126 170 return 127 171 } 128 172 129 - repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 130 - if err != nil { 173 + var xrpcResp types.RepoLogResponse 174 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 175 + log.Println("failed to decode XRPC response", err) 131 176 rp.pages.Error503(w) 132 - log.Println("failed to reach knotserver", err) 133 177 return 134 178 } 135 179 136 - tagResult, err := us.Tags(f.OwnerDid(), f.Name) 137 - if err != nil { 180 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 181 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 + log.Println("failed to call XRPC repo.tags", xrpcerr) 138 183 rp.pages.Error503(w) 139 - log.Println("failed to reach knotserver", err) 140 184 return 141 185 } 142 186 143 187 tagMap := make(map[string][]string) 144 - for _, tag := range tagResult.Tags { 145 - hash := tag.Hash 146 - if tag.Tag != nil { 147 - hash = tag.Tag.Target.String() 188 + if tagBytes != nil { 189 + var tagResp types.RepoTagsResponse 190 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 191 + for _, tag := range tagResp.Tags { 192 + tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 193 + } 148 194 } 149 - tagMap[hash] = append(tagMap[hash], tag.Name) 150 195 } 151 196 152 - branchResult, err := us.Branches(f.OwnerDid(), f.Name) 153 - if err != nil { 197 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 198 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 199 + log.Println("failed to call XRPC repo.branches", xrpcerr) 154 200 rp.pages.Error503(w) 155 - log.Println("failed to reach knotserver", err) 156 201 return 157 202 } 158 203 159 - for _, branch := range branchResult.Branches { 160 - hash := branch.Hash 161 - tagMap[hash] = append(tagMap[hash], branch.Name) 204 + if branchBytes != nil { 205 + var branchResp types.RepoBranchesResponse 206 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 207 + for _, branch := range branchResp.Branches { 208 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 209 + } 210 + } 162 211 } 163 212 164 213 user := rp.oauth.GetUser(r) 165 214 166 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 215 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 167 216 if err != nil { 168 217 log.Println("failed to fetch email to did mapping", err) 169 218 } 170 219 171 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 220 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 172 221 if err != nil { 173 222 log.Println(err) 174 223 } ··· 176 225 repoInfo := f.RepoInfo(user) 177 226 178 227 var shas []string 179 - for _, c := range repolog.Commits { 228 + for _, c := range xrpcResp.Commits { 180 229 shas = append(shas, c.Hash.String()) 181 230 } 182 231 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 189 238 LoggedInUser: user, 190 239 TagMap: tagMap, 191 240 RepoInfo: repoInfo, 192 - RepoLogResponse: *repolog, 241 + RepoLogResponse: xrpcResp, 193 242 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 194 243 VerifiedCommits: vc, 195 244 Pipelines: pipelines, ··· 251 300 return 252 301 } 253 302 303 + newRepo := f.Repo 304 + newRepo.Description = newDescription 305 + record := newRepo.AsRecord() 306 + 254 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 255 308 // 256 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 310 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 258 311 if err != nil { 259 312 // failed to get record 260 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 261 314 return 262 315 } 263 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 316 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 264 317 Collection: tangled.RepoNSID, 265 - Repo: user.Did, 266 - Rkey: rkey, 318 + Repo: newRepo.Did, 319 + Rkey: newRepo.Rkey, 267 320 SwapRecord: ex.Cid, 268 321 Record: &lexutil.LexiconTypeDecoder{ 269 - Val: &tangled.Repo{ 270 - Knot: f.Knot, 271 - Name: f.Name, 272 - Owner: user.Did, 273 - CreatedAt: f.Created.Format(time.RFC3339), 274 - Description: &newDescription, 275 - Spindle: &f.Spindle, 276 - }, 322 + Val: &record, 277 323 }, 278 324 }) 279 325 ··· 301 347 return 302 348 } 303 349 ref := chi.URLParam(r, "ref") 304 - protocol := "http" 305 - if !rp.config.Core.Dev { 306 - protocol = "https" 307 - } 350 + ref, _ = url.PathUnescape(ref) 308 351 309 352 var diffOpts types.DiffOpts 310 353 if d := r.URL.Query().Get("diff"); d == "split" { ··· 316 359 return 317 360 } 318 361 319 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 320 - if err != nil { 321 - rp.pages.Error503(w) 322 - log.Println("failed to reach knotserver", err) 323 - return 362 + scheme := "http" 363 + if !rp.config.Core.Dev { 364 + scheme = "https" 365 + } 366 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 367 + xrpcc := &indigoxrpc.Client{ 368 + Host: host, 324 369 } 325 370 326 - body, err := io.ReadAll(resp.Body) 327 - if err != nil { 328 - log.Printf("Error reading response body: %v", err) 371 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 372 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 373 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 374 + log.Println("failed to call XRPC repo.diff", xrpcerr) 375 + rp.pages.Error503(w) 329 376 return 330 377 } 331 378 332 379 var result types.RepoCommitResponse 333 - err = json.Unmarshal(body, &result) 334 - if err != nil { 335 - log.Println("failed to parse response:", err) 380 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 381 + log.Println("failed to decode XRPC response", err) 382 + rp.pages.Error503(w) 336 383 return 337 384 } 338 385 ··· 353 400 log.Println(err) 354 401 // non-fatal 355 402 } 356 - var pipeline *db.Pipeline 403 + var pipeline *models.Pipeline 357 404 if p, ok := pipelines[result.Diff.Commit.This]; ok { 358 405 pipeline = &p 359 406 } ··· 377 424 } 378 425 379 426 ref := chi.URLParam(r, "ref") 380 - treePath := chi.URLParam(r, "*") 381 - protocol := "http" 382 - if !rp.config.Core.Dev { 383 - protocol = "https" 384 - } 427 + ref, _ = url.PathUnescape(ref) 385 428 386 429 // if the tree path has a trailing slash, let's strip it 387 430 // so we don't 404 431 + treePath := chi.URLParam(r, "*") 432 + treePath, _ = url.PathUnescape(treePath) 388 433 treePath = strings.TrimSuffix(treePath, "/") 389 434 390 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 391 - if err != nil { 435 + scheme := "http" 436 + if !rp.config.Core.Dev { 437 + scheme = "https" 438 + } 439 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 440 + xrpcc := &indigoxrpc.Client{ 441 + Host: host, 442 + } 443 + 444 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 445 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 446 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 447 + log.Println("failed to call XRPC repo.tree", xrpcerr) 392 448 rp.pages.Error503(w) 393 - log.Println("failed to reach knotserver", err) 394 449 return 395 450 } 396 451 397 - // uhhh so knotserver returns a 500 if the entry isn't found in 398 - // the requested tree path, so let's stick to not-OK here. 399 - // we can fix this once we build out the xrpc apis for these operations. 400 - if resp.StatusCode != http.StatusOK { 401 - rp.pages.Error404(w) 402 - return 452 + // Convert XRPC response to internal types.RepoTreeResponse 453 + files := make([]types.NiceTree, len(xrpcResp.Files)) 454 + for i, xrpcFile := range xrpcResp.Files { 455 + file := types.NiceTree{ 456 + Name: xrpcFile.Name, 457 + Mode: xrpcFile.Mode, 458 + Size: int64(xrpcFile.Size), 459 + IsFile: xrpcFile.Is_file, 460 + IsSubtree: xrpcFile.Is_subtree, 461 + } 462 + 463 + // Convert last commit info if present 464 + if xrpcFile.Last_commit != nil { 465 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 466 + file.LastCommit = &types.LastCommitInfo{ 467 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 468 + Message: xrpcFile.Last_commit.Message, 469 + When: commitWhen, 470 + } 471 + } 472 + 473 + files[i] = file 403 474 } 404 475 405 - body, err := io.ReadAll(resp.Body) 406 - if err != nil { 407 - log.Printf("Error reading response body: %v", err) 408 - return 476 + result := types.RepoTreeResponse{ 477 + Ref: xrpcResp.Ref, 478 + Files: files, 409 479 } 410 480 411 - var result types.RepoTreeResponse 412 - err = json.Unmarshal(body, &result) 413 - if err != nil { 414 - log.Println("failed to parse response:", err) 415 - return 481 + if xrpcResp.Parent != nil { 482 + result.Parent = *xrpcResp.Parent 483 + } 484 + if xrpcResp.Dotdot != nil { 485 + result.DotDot = *xrpcResp.Dotdot 486 + } 487 + if xrpcResp.Readme != nil { 488 + result.ReadmeFileName = xrpcResp.Readme.Filename 489 + result.Readme = xrpcResp.Readme.Contents 416 490 } 417 491 418 492 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 419 493 // so we can safely redirect to the "parent" (which is the same file). 420 - unescapedTreePath, _ := url.PathUnescape(treePath) 421 - if len(result.Files) == 0 && result.Parent == unescapedTreePath { 422 - http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 494 + if len(result.Files) == 0 && result.Parent == treePath { 495 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 496 + http.Redirect(w, r, redirectTo, http.StatusFound) 423 497 return 424 498 } 425 499 426 500 user := rp.oauth.GetUser(r) 427 501 428 502 var breadcrumbs [][]string 429 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 503 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 430 504 if treePath != "" { 431 505 for idx, elem := range strings.Split(treePath, "/") { 432 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 506 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 433 507 } 434 508 } 435 509 ··· 451 525 return 452 526 } 453 527 454 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 455 - if err != nil { 456 - log.Println("failed to create unsigned client", err) 528 + scheme := "http" 529 + if !rp.config.Core.Dev { 530 + scheme = "https" 531 + } 532 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 533 + xrpcc := &indigoxrpc.Client{ 534 + Host: host, 535 + } 536 + 537 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 538 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 539 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 540 + log.Println("failed to call XRPC repo.tags", xrpcerr) 541 + rp.pages.Error503(w) 457 542 return 458 543 } 459 544 460 - result, err := us.Tags(f.OwnerDid(), f.Name) 461 - if err != nil { 545 + var result types.RepoTagsResponse 546 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 547 + log.Println("failed to decode XRPC response", err) 462 548 rp.pages.Error503(w) 463 - log.Println("failed to reach knotserver", err) 464 549 return 465 550 } 466 551 ··· 471 556 } 472 557 473 558 // convert artifacts to map for easy UI building 474 - artifactMap := make(map[plumbing.Hash][]db.Artifact) 559 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 475 560 for _, a := range artifacts { 476 561 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 477 562 } 478 563 479 - var danglingArtifacts []db.Artifact 564 + var danglingArtifacts []models.Artifact 480 565 for _, a := range artifacts { 481 566 found := false 482 567 for _, t := range result.Tags { ··· 496 581 rp.pages.RepoTags(w, pages.RepoTagsParams{ 497 582 LoggedInUser: user, 498 583 RepoInfo: f.RepoInfo(user), 499 - RepoTagsResponse: *result, 584 + RepoTagsResponse: result, 500 585 ArtifactMap: artifactMap, 501 586 DanglingArtifacts: danglingArtifacts, 502 587 }) ··· 509 594 return 510 595 } 511 596 512 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 513 - if err != nil { 514 - log.Println("failed to create unsigned client", err) 597 + scheme := "http" 598 + if !rp.config.Core.Dev { 599 + scheme = "https" 600 + } 601 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 602 + xrpcc := &indigoxrpc.Client{ 603 + Host: host, 604 + } 605 + 606 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 607 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 608 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 609 + log.Println("failed to call XRPC repo.branches", xrpcerr) 610 + rp.pages.Error503(w) 515 611 return 516 612 } 517 613 518 - result, err := us.Branches(f.OwnerDid(), f.Name) 519 - if err != nil { 614 + var result types.RepoBranchesResponse 615 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 616 + log.Println("failed to decode XRPC response", err) 520 617 rp.pages.Error503(w) 521 - log.Println("failed to reach knotserver", err) 522 618 return 523 619 } 524 620 ··· 528 624 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 529 625 LoggedInUser: user, 530 626 RepoInfo: f.RepoInfo(user), 531 - RepoBranchesResponse: *result, 627 + RepoBranchesResponse: result, 532 628 }) 533 629 } 534 630 535 - func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 631 + func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 536 632 f, err := rp.repoResolver.Resolve(r) 537 633 if err != nil { 538 634 log.Println("failed to get repo and knot", err) 539 635 return 540 636 } 541 637 542 - ref := chi.URLParam(r, "ref") 543 - filePath := chi.URLParam(r, "*") 544 - protocol := "http" 545 - if !rp.config.Core.Dev { 546 - protocol = "https" 638 + noticeId := "delete-branch-error" 639 + fail := func(msg string, err error) { 640 + log.Println(msg, "err", err) 641 + rp.pages.Notice(w, noticeId, msg) 547 642 } 548 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 643 + 644 + branch := r.FormValue("branch") 645 + if branch == "" { 646 + fail("No branch provided.", nil) 647 + return 648 + } 649 + 650 + client, err := rp.oauth.ServiceClient( 651 + r, 652 + oauth.WithService(f.Knot), 653 + oauth.WithLxm(tangled.RepoDeleteBranchNSID), 654 + oauth.WithDev(rp.config.Core.Dev), 655 + ) 549 656 if err != nil { 550 - rp.pages.Error503(w) 551 - log.Println("failed to reach knotserver", err) 657 + fail("Failed to connect to knotserver", nil) 552 658 return 553 659 } 554 660 555 - if resp.StatusCode == http.StatusNotFound { 556 - rp.pages.Error404(w) 661 + err = tangled.RepoDeleteBranch( 662 + r.Context(), 663 + client, 664 + &tangled.RepoDeleteBranch_Input{ 665 + Branch: branch, 666 + Repo: f.RepoAt().String(), 667 + }, 668 + ) 669 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 670 + fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 557 671 return 558 672 } 673 + log.Println("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 559 674 560 - body, err := io.ReadAll(resp.Body) 675 + rp.pages.HxRefresh(w) 676 + } 677 + 678 + func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 679 + f, err := rp.repoResolver.Resolve(r) 561 680 if err != nil { 562 - log.Printf("Error reading response body: %v", err) 681 + log.Println("failed to get repo and knot", err) 563 682 return 564 683 } 565 684 566 - var result types.RepoBlobResponse 567 - err = json.Unmarshal(body, &result) 568 - if err != nil { 569 - log.Println("failed to parse response:", err) 685 + ref := chi.URLParam(r, "ref") 686 + ref, _ = url.PathUnescape(ref) 687 + 688 + filePath := chi.URLParam(r, "*") 689 + filePath, _ = url.PathUnescape(filePath) 690 + 691 + scheme := "http" 692 + if !rp.config.Core.Dev { 693 + scheme = "https" 694 + } 695 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 696 + xrpcc := &indigoxrpc.Client{ 697 + Host: host, 698 + } 699 + 700 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 701 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 702 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 703 + log.Println("failed to call XRPC repo.blob", xrpcerr) 704 + rp.pages.Error503(w) 570 705 return 571 706 } 707 + 708 + // Use XRPC response directly instead of converting to internal types 572 709 573 710 var breadcrumbs [][]string 574 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 711 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 575 712 if filePath != "" { 576 713 for idx, elem := range strings.Split(filePath, "/") { 577 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 714 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 578 715 } 579 716 } 580 717 581 718 showRendered := false 582 719 renderToggle := false 583 720 584 - if markup.GetFormat(result.Path) == markup.FormatMarkdown { 721 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 585 722 renderToggle = true 586 723 showRendered = r.URL.Query().Get("code") != "true" 587 724 } ··· 591 728 var isVideo bool 592 729 var contentSrc string 593 730 594 - if result.IsBinary { 595 - ext := strings.ToLower(filepath.Ext(result.Path)) 731 + if resp.IsBinary != nil && *resp.IsBinary { 732 + ext := strings.ToLower(filepath.Ext(resp.Path)) 596 733 switch ext { 597 734 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 598 735 isImage = true ··· 602 739 unsupported = true 603 740 } 604 741 605 - // fetch the actual binary content like in RepoBlobRaw 742 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 743 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 606 744 607 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 745 + baseURL := &url.URL{ 746 + Scheme: scheme, 747 + Host: f.Knot, 748 + Path: "/xrpc/sh.tangled.repo.blob", 749 + } 750 + query := baseURL.Query() 751 + query.Set("repo", repoName) 752 + query.Set("ref", ref) 753 + query.Set("path", filePath) 754 + query.Set("raw", "true") 755 + baseURL.RawQuery = query.Encode() 756 + blobURL := baseURL.String() 757 + 608 758 contentSrc = blobURL 609 759 if !rp.config.Core.Dev { 610 760 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 611 761 } 612 762 } 613 763 764 + lines := 0 765 + if resp.IsBinary == nil || !*resp.IsBinary { 766 + lines = strings.Count(resp.Content, "\n") + 1 767 + } 768 + 769 + var sizeHint uint64 770 + if resp.Size != nil { 771 + sizeHint = uint64(*resp.Size) 772 + } else { 773 + sizeHint = uint64(len(resp.Content)) 774 + } 775 + 614 776 user := rp.oauth.GetUser(r) 777 + 778 + // Determine if content is binary (dereference pointer) 779 + isBinary := false 780 + if resp.IsBinary != nil { 781 + isBinary = *resp.IsBinary 782 + } 783 + 615 784 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 616 - LoggedInUser: user, 617 - RepoInfo: f.RepoInfo(user), 618 - RepoBlobResponse: result, 619 - BreadCrumbs: breadcrumbs, 620 - ShowRendered: showRendered, 621 - RenderToggle: renderToggle, 622 - Unsupported: unsupported, 623 - IsImage: isImage, 624 - IsVideo: isVideo, 625 - ContentSrc: contentSrc, 785 + LoggedInUser: user, 786 + RepoInfo: f.RepoInfo(user), 787 + BreadCrumbs: breadcrumbs, 788 + ShowRendered: showRendered, 789 + RenderToggle: renderToggle, 790 + Unsupported: unsupported, 791 + IsImage: isImage, 792 + IsVideo: isVideo, 793 + ContentSrc: contentSrc, 794 + RepoBlob_Output: resp, 795 + Contents: resp.Content, 796 + Lines: lines, 797 + SizeHint: sizeHint, 798 + IsBinary: isBinary, 626 799 }) 627 800 } 628 801 ··· 635 808 } 636 809 637 810 ref := chi.URLParam(r, "ref") 811 + ref, _ = url.PathUnescape(ref) 812 + 638 813 filePath := chi.URLParam(r, "*") 814 + filePath, _ = url.PathUnescape(filePath) 639 815 640 - protocol := "http" 816 + scheme := "http" 641 817 if !rp.config.Core.Dev { 642 - protocol = "https" 818 + scheme = "https" 643 819 } 644 820 645 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 821 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 822 + baseURL := &url.URL{ 823 + Scheme: scheme, 824 + Host: f.Knot, 825 + Path: "/xrpc/sh.tangled.repo.blob", 826 + } 827 + query := baseURL.Query() 828 + query.Set("repo", repo) 829 + query.Set("ref", ref) 830 + query.Set("path", filePath) 831 + query.Set("raw", "true") 832 + baseURL.RawQuery = query.Encode() 833 + blobURL := baseURL.String() 646 834 647 835 req, err := http.NewRequest("GET", blobURL, nil) 648 836 if err != nil { ··· 685 873 return 686 874 } 687 875 688 - if strings.Contains(contentType, "text/plain") { 876 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 877 + // serve all textual content as text/plain 689 878 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 690 879 w.Write(body) 691 880 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 881 + // serve images and videos with their original content type 692 882 w.Header().Set("Content-Type", contentType) 693 883 w.Write(body) 694 884 } else { ··· 698 888 } 699 889 } 700 890 891 + // isTextualMimeType returns true if the MIME type represents textual content 892 + // that should be served as text/plain 893 + func isTextualMimeType(mimeType string) bool { 894 + textualTypes := []string{ 895 + "application/json", 896 + "application/xml", 897 + "application/yaml", 898 + "application/x-yaml", 899 + "application/toml", 900 + "application/javascript", 901 + "application/ecmascript", 902 + "message/", 903 + } 904 + 905 + return slices.Contains(textualTypes, mimeType) 906 + } 907 + 701 908 // modify the spindle configured for this repo 702 909 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 703 910 user := rp.oauth.GetUser(r) 704 911 l := rp.logger.With("handler", "EditSpindle") 705 912 l = l.With("did", user.Did) 706 - l = l.With("handle", user.Handle) 707 913 708 914 errorId := "operation-error" 709 915 fail := func(msg string, err error) { ··· 717 923 return 718 924 } 719 925 720 - repoAt := f.RepoAt() 721 - rkey := repoAt.RecordKey().String() 722 - if rkey == "" { 723 - fail("Failed to resolve repo. Try again later", err) 724 - return 725 - } 726 - 727 926 newSpindle := r.FormValue("spindle") 728 927 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 729 928 client, err := rp.oauth.AuthorizedClient(r) ··· 746 945 } 747 946 } 748 947 948 + newRepo := f.Repo 949 + newRepo.Spindle = newSpindle 950 + record := newRepo.AsRecord() 951 + 749 952 spindlePtr := &newSpindle 750 953 if removingSpindle { 751 954 spindlePtr = nil 955 + newRepo.Spindle = "" 752 956 } 753 957 754 958 // optimistic update 755 - err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 959 + err = db.UpdateSpindle(rp.db, newRepo.RepoAt().String(), spindlePtr) 756 960 if err != nil { 757 961 fail("Failed to update spindle. Try again later.", err) 758 962 return 759 963 } 760 964 761 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 965 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 762 966 if err != nil { 763 967 fail("Failed to update spindle, no record found on PDS.", err) 764 968 return 765 969 } 766 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 970 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 767 971 Collection: tangled.RepoNSID, 768 - Repo: user.Did, 769 - Rkey: rkey, 972 + Repo: newRepo.Did, 973 + Rkey: newRepo.Rkey, 770 974 SwapRecord: ex.Cid, 771 975 Record: &lexutil.LexiconTypeDecoder{ 772 - Val: &tangled.Repo{ 773 - Knot: f.Knot, 774 - Name: f.Name, 775 - Owner: user.Did, 776 - CreatedAt: f.Created.Format(time.RFC3339), 777 - Description: &f.Description, 778 - Spindle: spindlePtr, 779 - }, 976 + Val: &record, 780 977 }, 781 978 }) 782 979 ··· 796 993 rp.pages.HxRefresh(w) 797 994 } 798 995 996 + func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { 997 + user := rp.oauth.GetUser(r) 998 + l := rp.logger.With("handler", "AddLabel") 999 + l = l.With("did", user.Did) 1000 + 1001 + f, err := rp.repoResolver.Resolve(r) 1002 + if err != nil { 1003 + l.Error("failed to get repo and knot", "err", err) 1004 + return 1005 + } 1006 + 1007 + errorId := "add-label-error" 1008 + fail := func(msg string, err error) { 1009 + l.Error(msg, "err", err) 1010 + rp.pages.Notice(w, errorId, msg) 1011 + } 1012 + 1013 + // get form values for label definition 1014 + name := r.FormValue("name") 1015 + concreteType := r.FormValue("valueType") 1016 + valueFormat := r.FormValue("valueFormat") 1017 + enumValues := r.FormValue("enumValues") 1018 + scope := r.Form["scope"] 1019 + color := r.FormValue("color") 1020 + multiple := r.FormValue("multiple") == "true" 1021 + 1022 + var variants []string 1023 + for part := range strings.SplitSeq(enumValues, ",") { 1024 + if part = strings.TrimSpace(part); part != "" { 1025 + variants = append(variants, part) 1026 + } 1027 + } 1028 + 1029 + if concreteType == "" { 1030 + concreteType = "null" 1031 + } 1032 + 1033 + format := models.ValueTypeFormatAny 1034 + if valueFormat == "did" { 1035 + format = models.ValueTypeFormatDid 1036 + } 1037 + 1038 + valueType := models.ValueType{ 1039 + Type: models.ConcreteType(concreteType), 1040 + Format: format, 1041 + Enum: variants, 1042 + } 1043 + 1044 + label := models.LabelDefinition{ 1045 + Did: user.Did, 1046 + Rkey: tid.TID(), 1047 + Name: name, 1048 + ValueType: valueType, 1049 + Scope: scope, 1050 + Color: &color, 1051 + Multiple: multiple, 1052 + Created: time.Now(), 1053 + } 1054 + if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 1055 + fail(err.Error(), err) 1056 + return 1057 + } 1058 + 1059 + // announce this relation into the firehose, store into owners' pds 1060 + client, err := rp.oauth.AuthorizedClient(r) 1061 + if err != nil { 1062 + fail(err.Error(), err) 1063 + return 1064 + } 1065 + 1066 + // emit a labelRecord 1067 + labelRecord := label.AsRecord() 1068 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1069 + Collection: tangled.LabelDefinitionNSID, 1070 + Repo: label.Did, 1071 + Rkey: label.Rkey, 1072 + Record: &lexutil.LexiconTypeDecoder{ 1073 + Val: &labelRecord, 1074 + }, 1075 + }) 1076 + // invalid record 1077 + if err != nil { 1078 + fail("Failed to write record to PDS.", err) 1079 + return 1080 + } 1081 + 1082 + aturi := resp.Uri 1083 + l = l.With("at-uri", aturi) 1084 + l.Info("wrote label record to PDS") 1085 + 1086 + // update the repo to subscribe to this label 1087 + newRepo := f.Repo 1088 + newRepo.Labels = append(newRepo.Labels, aturi) 1089 + repoRecord := newRepo.AsRecord() 1090 + 1091 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1092 + if err != nil { 1093 + fail("Failed to update labels, no record found on PDS.", err) 1094 + return 1095 + } 1096 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1097 + Collection: tangled.RepoNSID, 1098 + Repo: newRepo.Did, 1099 + Rkey: newRepo.Rkey, 1100 + SwapRecord: ex.Cid, 1101 + Record: &lexutil.LexiconTypeDecoder{ 1102 + Val: &repoRecord, 1103 + }, 1104 + }) 1105 + if err != nil { 1106 + fail("Failed to update labels for repo.", err) 1107 + return 1108 + } 1109 + 1110 + tx, err := rp.db.BeginTx(r.Context(), nil) 1111 + if err != nil { 1112 + fail("Failed to add label.", err) 1113 + return 1114 + } 1115 + 1116 + rollback := func() { 1117 + err1 := tx.Rollback() 1118 + err2 := rollbackRecord(context.Background(), aturi, client) 1119 + 1120 + // ignore txn complete errors, this is okay 1121 + if errors.Is(err1, sql.ErrTxDone) { 1122 + err1 = nil 1123 + } 1124 + 1125 + if errs := errors.Join(err1, err2); errs != nil { 1126 + l.Error("failed to rollback changes", "errs", errs) 1127 + return 1128 + } 1129 + } 1130 + defer rollback() 1131 + 1132 + _, err = db.AddLabelDefinition(tx, &label) 1133 + if err != nil { 1134 + fail("Failed to add label.", err) 1135 + return 1136 + } 1137 + 1138 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1139 + RepoAt: f.RepoAt(), 1140 + LabelAt: label.AtUri(), 1141 + }) 1142 + 1143 + err = tx.Commit() 1144 + if err != nil { 1145 + fail("Failed to add label.", err) 1146 + return 1147 + } 1148 + 1149 + // clear aturi when everything is successful 1150 + aturi = "" 1151 + 1152 + rp.pages.HxRefresh(w) 1153 + } 1154 + 1155 + func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { 1156 + user := rp.oauth.GetUser(r) 1157 + l := rp.logger.With("handler", "DeleteLabel") 1158 + l = l.With("did", user.Did) 1159 + 1160 + f, err := rp.repoResolver.Resolve(r) 1161 + if err != nil { 1162 + l.Error("failed to get repo and knot", "err", err) 1163 + return 1164 + } 1165 + 1166 + errorId := "label-operation" 1167 + fail := func(msg string, err error) { 1168 + l.Error(msg, "err", err) 1169 + rp.pages.Notice(w, errorId, msg) 1170 + } 1171 + 1172 + // get form values 1173 + labelId := r.FormValue("label-id") 1174 + 1175 + label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId)) 1176 + if err != nil { 1177 + fail("Failed to find label definition.", err) 1178 + return 1179 + } 1180 + 1181 + client, err := rp.oauth.AuthorizedClient(r) 1182 + if err != nil { 1183 + fail(err.Error(), err) 1184 + return 1185 + } 1186 + 1187 + // delete label record from PDS 1188 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1189 + Collection: tangled.LabelDefinitionNSID, 1190 + Repo: label.Did, 1191 + Rkey: label.Rkey, 1192 + }) 1193 + if err != nil { 1194 + fail("Failed to delete label record from PDS.", err) 1195 + return 1196 + } 1197 + 1198 + // update repo record to remove the label reference 1199 + newRepo := f.Repo 1200 + var updated []string 1201 + removedAt := label.AtUri().String() 1202 + for _, l := range newRepo.Labels { 1203 + if l != removedAt { 1204 + updated = append(updated, l) 1205 + } 1206 + } 1207 + newRepo.Labels = updated 1208 + repoRecord := newRepo.AsRecord() 1209 + 1210 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1211 + if err != nil { 1212 + fail("Failed to update labels, no record found on PDS.", err) 1213 + return 1214 + } 1215 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1216 + Collection: tangled.RepoNSID, 1217 + Repo: newRepo.Did, 1218 + Rkey: newRepo.Rkey, 1219 + SwapRecord: ex.Cid, 1220 + Record: &lexutil.LexiconTypeDecoder{ 1221 + Val: &repoRecord, 1222 + }, 1223 + }) 1224 + if err != nil { 1225 + fail("Failed to update repo record.", err) 1226 + return 1227 + } 1228 + 1229 + // transaction for DB changes 1230 + tx, err := rp.db.BeginTx(r.Context(), nil) 1231 + if err != nil { 1232 + fail("Failed to delete label.", err) 1233 + return 1234 + } 1235 + defer tx.Rollback() 1236 + 1237 + err = db.UnsubscribeLabel( 1238 + tx, 1239 + db.FilterEq("repo_at", f.RepoAt()), 1240 + db.FilterEq("label_at", removedAt), 1241 + ) 1242 + if err != nil { 1243 + fail("Failed to unsubscribe label.", err) 1244 + return 1245 + } 1246 + 1247 + err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id)) 1248 + if err != nil { 1249 + fail("Failed to delete label definition.", err) 1250 + return 1251 + } 1252 + 1253 + err = tx.Commit() 1254 + if err != nil { 1255 + fail("Failed to delete label.", err) 1256 + return 1257 + } 1258 + 1259 + // everything succeeded 1260 + rp.pages.HxRefresh(w) 1261 + } 1262 + 1263 + func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { 1264 + user := rp.oauth.GetUser(r) 1265 + l := rp.logger.With("handler", "SubscribeLabel") 1266 + l = l.With("did", user.Did) 1267 + 1268 + f, err := rp.repoResolver.Resolve(r) 1269 + if err != nil { 1270 + l.Error("failed to get repo and knot", "err", err) 1271 + return 1272 + } 1273 + 1274 + if err := r.ParseForm(); err != nil { 1275 + l.Error("invalid form", "err", err) 1276 + return 1277 + } 1278 + 1279 + errorId := "default-label-operation" 1280 + fail := func(msg string, err error) { 1281 + l.Error(msg, "err", err) 1282 + rp.pages.Notice(w, errorId, msg) 1283 + } 1284 + 1285 + labelAts := r.Form["label"] 1286 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1287 + if err != nil { 1288 + fail("Failed to subscribe to label.", err) 1289 + return 1290 + } 1291 + 1292 + newRepo := f.Repo 1293 + newRepo.Labels = append(newRepo.Labels, labelAts...) 1294 + 1295 + // dedup 1296 + slices.Sort(newRepo.Labels) 1297 + newRepo.Labels = slices.Compact(newRepo.Labels) 1298 + 1299 + repoRecord := newRepo.AsRecord() 1300 + 1301 + client, err := rp.oauth.AuthorizedClient(r) 1302 + if err != nil { 1303 + fail(err.Error(), err) 1304 + return 1305 + } 1306 + 1307 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1308 + if err != nil { 1309 + fail("Failed to update labels, no record found on PDS.", err) 1310 + return 1311 + } 1312 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1313 + Collection: tangled.RepoNSID, 1314 + Repo: newRepo.Did, 1315 + Rkey: newRepo.Rkey, 1316 + SwapRecord: ex.Cid, 1317 + Record: &lexutil.LexiconTypeDecoder{ 1318 + Val: &repoRecord, 1319 + }, 1320 + }) 1321 + 1322 + tx, err := rp.db.Begin() 1323 + if err != nil { 1324 + fail("Failed to subscribe to label.", err) 1325 + return 1326 + } 1327 + defer tx.Rollback() 1328 + 1329 + for _, l := range labelAts { 1330 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1331 + RepoAt: f.RepoAt(), 1332 + LabelAt: syntax.ATURI(l), 1333 + }) 1334 + if err != nil { 1335 + fail("Failed to subscribe to label.", err) 1336 + return 1337 + } 1338 + } 1339 + 1340 + if err := tx.Commit(); err != nil { 1341 + fail("Failed to subscribe to label.", err) 1342 + return 1343 + } 1344 + 1345 + // everything succeeded 1346 + rp.pages.HxRefresh(w) 1347 + } 1348 + 1349 + func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { 1350 + user := rp.oauth.GetUser(r) 1351 + l := rp.logger.With("handler", "UnsubscribeLabel") 1352 + l = l.With("did", user.Did) 1353 + 1354 + f, err := rp.repoResolver.Resolve(r) 1355 + if err != nil { 1356 + l.Error("failed to get repo and knot", "err", err) 1357 + return 1358 + } 1359 + 1360 + if err := r.ParseForm(); err != nil { 1361 + l.Error("invalid form", "err", err) 1362 + return 1363 + } 1364 + 1365 + errorId := "default-label-operation" 1366 + fail := func(msg string, err error) { 1367 + l.Error(msg, "err", err) 1368 + rp.pages.Notice(w, errorId, msg) 1369 + } 1370 + 1371 + labelAts := r.Form["label"] 1372 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1373 + if err != nil { 1374 + fail("Failed to unsubscribe to label.", err) 1375 + return 1376 + } 1377 + 1378 + // update repo record to remove the label reference 1379 + newRepo := f.Repo 1380 + var updated []string 1381 + for _, l := range newRepo.Labels { 1382 + if !slices.Contains(labelAts, l) { 1383 + updated = append(updated, l) 1384 + } 1385 + } 1386 + newRepo.Labels = updated 1387 + repoRecord := newRepo.AsRecord() 1388 + 1389 + client, err := rp.oauth.AuthorizedClient(r) 1390 + if err != nil { 1391 + fail(err.Error(), err) 1392 + return 1393 + } 1394 + 1395 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1396 + if err != nil { 1397 + fail("Failed to update labels, no record found on PDS.", err) 1398 + return 1399 + } 1400 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1401 + Collection: tangled.RepoNSID, 1402 + Repo: newRepo.Did, 1403 + Rkey: newRepo.Rkey, 1404 + SwapRecord: ex.Cid, 1405 + Record: &lexutil.LexiconTypeDecoder{ 1406 + Val: &repoRecord, 1407 + }, 1408 + }) 1409 + 1410 + err = db.UnsubscribeLabel( 1411 + rp.db, 1412 + db.FilterEq("repo_at", f.RepoAt()), 1413 + db.FilterIn("label_at", labelAts), 1414 + ) 1415 + if err != nil { 1416 + fail("Failed to unsubscribe label.", err) 1417 + return 1418 + } 1419 + 1420 + // everything succeeded 1421 + rp.pages.HxRefresh(w) 1422 + } 1423 + 1424 + func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) { 1425 + l := rp.logger.With("handler", "LabelPanel") 1426 + 1427 + f, err := rp.repoResolver.Resolve(r) 1428 + if err != nil { 1429 + l.Error("failed to get repo and knot", "err", err) 1430 + return 1431 + } 1432 + 1433 + subjectStr := r.FormValue("subject") 1434 + subject, err := syntax.ParseATURI(subjectStr) 1435 + if err != nil { 1436 + l.Error("failed to get repo and knot", "err", err) 1437 + return 1438 + } 1439 + 1440 + labelDefs, err := db.GetLabelDefinitions( 1441 + rp.db, 1442 + db.FilterIn("at_uri", f.Repo.Labels), 1443 + db.FilterContains("scope", subject.Collection().String()), 1444 + ) 1445 + if err != nil { 1446 + log.Println("failed to fetch label defs", err) 1447 + return 1448 + } 1449 + 1450 + defs := make(map[string]*models.LabelDefinition) 1451 + for _, l := range labelDefs { 1452 + defs[l.AtUri().String()] = &l 1453 + } 1454 + 1455 + states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1456 + if err != nil { 1457 + log.Println("failed to build label state", err) 1458 + return 1459 + } 1460 + state := states[subject] 1461 + 1462 + user := rp.oauth.GetUser(r) 1463 + rp.pages.LabelPanel(w, pages.LabelPanelParams{ 1464 + LoggedInUser: user, 1465 + RepoInfo: f.RepoInfo(user), 1466 + Defs: defs, 1467 + Subject: subject.String(), 1468 + State: state, 1469 + }) 1470 + } 1471 + 1472 + func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) { 1473 + l := rp.logger.With("handler", "EditLabelPanel") 1474 + 1475 + f, err := rp.repoResolver.Resolve(r) 1476 + if err != nil { 1477 + l.Error("failed to get repo and knot", "err", err) 1478 + return 1479 + } 1480 + 1481 + subjectStr := r.FormValue("subject") 1482 + subject, err := syntax.ParseATURI(subjectStr) 1483 + if err != nil { 1484 + l.Error("failed to get repo and knot", "err", err) 1485 + return 1486 + } 1487 + 1488 + labelDefs, err := db.GetLabelDefinitions( 1489 + rp.db, 1490 + db.FilterIn("at_uri", f.Repo.Labels), 1491 + db.FilterContains("scope", subject.Collection().String()), 1492 + ) 1493 + if err != nil { 1494 + log.Println("failed to fetch labels", err) 1495 + return 1496 + } 1497 + 1498 + defs := make(map[string]*models.LabelDefinition) 1499 + for _, l := range labelDefs { 1500 + defs[l.AtUri().String()] = &l 1501 + } 1502 + 1503 + states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1504 + if err != nil { 1505 + log.Println("failed to build label state", err) 1506 + return 1507 + } 1508 + state := states[subject] 1509 + 1510 + user := rp.oauth.GetUser(r) 1511 + rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 1512 + LoggedInUser: user, 1513 + RepoInfo: f.RepoInfo(user), 1514 + Defs: defs, 1515 + Subject: subject.String(), 1516 + State: state, 1517 + }) 1518 + } 1519 + 799 1520 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 800 1521 user := rp.oauth.GetUser(r) 801 1522 l := rp.logger.With("handler", "AddCollaborator") 802 1523 l = l.With("did", user.Did) 803 - l = l.With("handle", user.Handle) 804 1524 805 1525 f, err := rp.repoResolver.Resolve(r) 806 1526 if err != nil { ··· 847 1567 currentUser := rp.oauth.GetUser(r) 848 1568 rkey := tid.TID() 849 1569 createdAt := time.Now() 850 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1570 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 851 1571 Collection: tangled.RepoCollaboratorNSID, 852 1572 Repo: currentUser.Did, 853 1573 Rkey: rkey, ··· 897 1617 return 898 1618 } 899 1619 900 - err = db.AddCollaborator(rp.db, db.Collaborator{ 1620 + err = db.AddCollaborator(tx, models.Collaborator{ 901 1621 Did: syntax.DID(currentUser.Did), 902 1622 Rkey: rkey, 903 1623 SubjectDid: collaboratorIdent.DID, ··· 938 1658 } 939 1659 940 1660 // remove record from pds 941 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1661 + atpClient, err := rp.oauth.AuthorizedClient(r) 942 1662 if err != nil { 943 1663 log.Println("failed to get authorized client", err) 944 1664 return 945 1665 } 946 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1666 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 947 1667 Collection: tangled.RepoNSID, 948 1668 Repo: user.Did, 949 1669 Rkey: f.Rkey, ··· 1085 1805 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1086 1806 user := rp.oauth.GetUser(r) 1087 1807 l := rp.logger.With("handler", "Secrets") 1088 - l = l.With("handle", user.Handle) 1089 1808 l = l.With("did", user.Did) 1090 1809 1091 1810 f, err := rp.repoResolver.Resolve(r) ··· 1201 1920 f, err := rp.repoResolver.Resolve(r) 1202 1921 user := rp.oauth.GetUser(r) 1203 1922 1204 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1923 + scheme := "http" 1924 + if !rp.config.Core.Dev { 1925 + scheme = "https" 1926 + } 1927 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1928 + xrpcc := &indigoxrpc.Client{ 1929 + Host: host, 1930 + } 1931 + 1932 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1933 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1934 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1935 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1936 + rp.pages.Error503(w) 1937 + return 1938 + } 1939 + 1940 + var result types.RepoBranchesResponse 1941 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1942 + log.Println("failed to decode XRPC response", err) 1943 + rp.pages.Error503(w) 1944 + return 1945 + } 1946 + 1947 + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1205 1948 if err != nil { 1206 - log.Println("failed to create unsigned client", err) 1949 + log.Println("failed to fetch labels", err) 1950 + rp.pages.Error503(w) 1207 1951 return 1208 1952 } 1209 1953 1210 - result, err := us.Branches(f.OwnerDid(), f.Name) 1954 + labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1211 1955 if err != nil { 1956 + log.Println("failed to fetch labels", err) 1212 1957 rp.pages.Error503(w) 1213 - log.Println("failed to reach knotserver", err) 1214 1958 return 1215 1959 } 1960 + // remove default labels from the labels list, if present 1961 + defaultLabelMap := make(map[string]bool) 1962 + for _, dl := range defaultLabels { 1963 + defaultLabelMap[dl.AtUri().String()] = true 1964 + } 1965 + n := 0 1966 + for _, l := range labels { 1967 + if !defaultLabelMap[l.AtUri().String()] { 1968 + labels[n] = l 1969 + n++ 1970 + } 1971 + } 1972 + labels = labels[:n] 1973 + 1974 + subscribedLabels := make(map[string]struct{}) 1975 + for _, l := range f.Repo.Labels { 1976 + subscribedLabels[l] = struct{}{} 1977 + } 1978 + 1979 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1980 + // if all default labels are subbed, show the "unsubscribe all" button 1981 + shouldSubscribeAll := false 1982 + for _, dl := range defaultLabels { 1983 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1984 + // one of the default labels is not subscribed to 1985 + shouldSubscribeAll = true 1986 + break 1987 + } 1988 + } 1216 1989 1217 1990 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1218 - LoggedInUser: user, 1219 - RepoInfo: f.RepoInfo(user), 1220 - Branches: result.Branches, 1221 - Tabs: settingsTabs, 1222 - Tab: "general", 1991 + LoggedInUser: user, 1992 + RepoInfo: f.RepoInfo(user), 1993 + Branches: result.Branches, 1994 + Labels: labels, 1995 + DefaultLabels: defaultLabels, 1996 + SubscribedLabels: subscribedLabels, 1997 + ShouldSubscribeAll: shouldSubscribeAll, 1998 + Tabs: settingsTabs, 1999 + Tab: "general", 1223 2000 }) 1224 2001 } 1225 2002 ··· 1304 2081 1305 2082 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1306 2083 ref := chi.URLParam(r, "ref") 2084 + ref, _ = url.PathUnescape(ref) 1307 2085 1308 2086 user := rp.oauth.GetUser(r) 1309 2087 f, err := rp.repoResolver.Resolve(r) ··· 1391 2169 } 1392 2170 1393 2171 // choose a name for a fork 1394 - forkName := f.Name 2172 + forkName := r.FormValue("repo_name") 2173 + if forkName == "" { 2174 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2175 + return 2176 + } 2177 + 1395 2178 // this check is *only* to see if the forked repo name already exists 1396 2179 // in the user's account. 1397 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 2180 + existingRepo, err := db.GetRepo( 2181 + rp.db, 2182 + db.FilterEq("did", user.Did), 2183 + db.FilterEq("name", forkName), 2184 + ) 1398 2185 if err != nil { 1399 - if errors.Is(err, sql.ErrNoRows) { 1400 - // no existing repo with this name found, we can use the name as is 1401 - } else { 1402 - log.Println("error fetching existing repo from db", err) 2186 + if !errors.Is(err, sql.ErrNoRows) { 2187 + log.Println("error fetching existing repo from db", "err", err) 1403 2188 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1404 2189 return 1405 2190 } 1406 2191 } else if existingRepo != nil { 1407 - // repo with this name already exists, append random string 1408 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2192 + // repo with this name already exists 2193 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2194 + return 1409 2195 } 1410 2196 l = l.With("forkName", forkName) 1411 2197 ··· 1421 2207 1422 2208 // create an atproto record for this fork 1423 2209 rkey := tid.TID() 1424 - repo := &db.Repo{ 1425 - Did: user.Did, 1426 - Name: forkName, 1427 - Knot: targetKnot, 1428 - Rkey: rkey, 1429 - Source: sourceAt, 2210 + repo := &models.Repo{ 2211 + Did: user.Did, 2212 + Name: forkName, 2213 + Knot: targetKnot, 2214 + Rkey: rkey, 2215 + Source: sourceAt, 2216 + Description: f.Repo.Description, 2217 + Created: time.Now(), 2218 + Labels: models.DefaultLabelDefs(), 1430 2219 } 2220 + record := repo.AsRecord() 1431 2221 1432 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2222 + atpClient, err := rp.oauth.AuthorizedClient(r) 1433 2223 if err != nil { 1434 2224 l.Error("failed to create xrpcclient", "err", err) 1435 2225 rp.pages.Notice(w, "repo", "Failed to fork repository.") 1436 2226 return 1437 2227 } 1438 2228 1439 - createdAt := time.Now().Format(time.RFC3339) 1440 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2229 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 1441 2230 Collection: tangled.RepoNSID, 1442 2231 Repo: user.Did, 1443 2232 Rkey: rkey, 1444 2233 Record: &lexutil.LexiconTypeDecoder{ 1445 - Val: &tangled.Repo{ 1446 - Knot: repo.Knot, 1447 - Name: repo.Name, 1448 - CreatedAt: createdAt, 1449 - Owner: user.Did, 1450 - Source: &sourceAt, 1451 - }}, 2234 + Val: &record, 2235 + }, 1452 2236 }) 1453 2237 if err != nil { 1454 2238 l.Error("failed to write to PDS", "err", err) ··· 1474 2258 rollback := func() { 1475 2259 err1 := tx.Rollback() 1476 2260 err2 := rp.enforcer.E.LoadPolicy() 1477 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2261 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 1478 2262 1479 2263 // ignore txn complete errors, this is okay 1480 2264 if errors.Is(err1, sql.ErrTxDone) { ··· 1547 2331 aturi = "" 1548 2332 1549 2333 rp.notifier.NewRepo(r.Context(), repo) 1550 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2334 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 1551 2335 } 1552 2336 } 1553 2337 1554 2338 // this is used to rollback changes made to the PDS 1555 2339 // 1556 2340 // it is a no-op if the provided ATURI is empty 1557 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2341 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1558 2342 if aturi == "" { 1559 2343 return nil 1560 2344 } ··· 1565 2349 repo := parsed.Authority().String() 1566 2350 rkey := parsed.RecordKey().String() 1567 2351 1568 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2352 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 1569 2353 Collection: collection, 1570 2354 Repo: repo, 1571 2355 Rkey: rkey, ··· 1581 2365 return 1582 2366 } 1583 2367 1584 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1585 - if err != nil { 1586 - log.Printf("failed to create unsigned client for %s", f.Knot) 2368 + scheme := "http" 2369 + if !rp.config.Core.Dev { 2370 + scheme = "https" 2371 + } 2372 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2373 + xrpcc := &indigoxrpc.Client{ 2374 + Host: host, 2375 + } 2376 + 2377 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2378 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2379 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2380 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1587 2381 rp.pages.Error503(w) 1588 2382 return 1589 2383 } 1590 2384 1591 - result, err := us.Branches(f.OwnerDid(), f.Name) 1592 - if err != nil { 2385 + var branchResult types.RepoBranchesResponse 2386 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2387 + log.Println("failed to decode XRPC branches response", err) 1593 2388 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1594 - log.Println("failed to reach knotserver", err) 1595 2389 return 1596 2390 } 1597 - branches := result.Branches 2391 + branches := branchResult.Branches 1598 2392 1599 2393 sortBranches(branches) 1600 2394 ··· 1618 2412 head = queryHead 1619 2413 } 1620 2414 1621 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1622 - if err != nil { 2415 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2416 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2417 + log.Println("failed to call XRPC repo.tags", xrpcerr) 2418 + rp.pages.Error503(w) 2419 + return 2420 + } 2421 + 2422 + var tags types.RepoTagsResponse 2423 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 2424 + log.Println("failed to decode XRPC tags response", err) 1623 2425 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1624 - log.Println("failed to reach knotserver", err) 1625 2426 return 1626 2427 } 1627 2428 ··· 1673 2474 return 1674 2475 } 1675 2476 1676 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1677 - if err != nil { 1678 - log.Printf("failed to create unsigned client for %s", f.Knot) 2477 + scheme := "http" 2478 + if !rp.config.Core.Dev { 2479 + scheme = "https" 2480 + } 2481 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2482 + xrpcc := &indigoxrpc.Client{ 2483 + Host: host, 2484 + } 2485 + 2486 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2487 + 2488 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2489 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2490 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1679 2491 rp.pages.Error503(w) 1680 2492 return 1681 2493 } 1682 2494 1683 - branches, err := us.Branches(f.OwnerDid(), f.Name) 1684 - if err != nil { 2495 + var branches types.RepoBranchesResponse 2496 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 2497 + log.Println("failed to decode XRPC branches response", err) 1685 2498 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1686 - log.Println("failed to reach knotserver", err) 2499 + return 2500 + } 2501 + 2502 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2503 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2504 + log.Println("failed to call XRPC repo.tags", xrpcerr) 2505 + rp.pages.Error503(w) 1687 2506 return 1688 2507 } 1689 2508 1690 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1691 - if err != nil { 2509 + var tags types.RepoTagsResponse 2510 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 2511 + log.Println("failed to decode XRPC tags response", err) 1692 2512 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1693 - log.Println("failed to reach knotserver", err) 1694 2513 return 1695 2514 } 1696 2515 1697 - formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1698 - if err != nil { 2516 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2517 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2518 + log.Println("failed to call XRPC repo.compare", xrpcerr) 2519 + rp.pages.Error503(w) 2520 + return 2521 + } 2522 + 2523 + var formatPatch types.RepoFormatPatchResponse 2524 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2525 + log.Println("failed to decode XRPC compare response", err) 1699 2526 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1700 - log.Println("failed to compare", err) 1701 2527 return 1702 2528 } 2529 + 1703 2530 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1704 2531 1705 2532 repoinfo := f.RepoInfo(user)
+6 -5
appview/repo/repo_util.go
··· 9 9 "sort" 10 10 "strings" 11 11 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 14 - "tangled.sh/tangled.sh/core/types" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pages/repoinfo" 15 + "tangled.org/core/types" 15 16 16 17 "github.com/go-git/go-git/v5/plumbing/object" 17 18 ) ··· 143 144 d *db.DB, 144 145 repoInfo repoinfo.RepoInfo, 145 146 shas []string, 146 - ) (map[string]db.Pipeline, error) { 147 - m := make(map[string]db.Pipeline) 147 + ) (map[string]models.Pipeline, error) { 148 + m := make(map[string]models.Pipeline) 148 149 149 150 if len(shas) == 0 { 150 151 return m, nil
+15 -4
appview/repo/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/opengraph", rp.RepoOpenGraphSummary) 13 14 r.Get("/feed.atom", rp.RepoAtomFeed) 14 15 r.Get("/commits/{ref}", rp.RepoLog) 15 16 r.Route("/tree/{ref}", func(r chi.Router) { ··· 18 19 }) 19 20 r.Get("/commit/{ref}", rp.RepoCommit) 20 21 r.Get("/branches", rp.RepoBranches) 22 + r.Delete("/branches", rp.DeleteBranch) 21 23 r.Route("/tags", func(r chi.Router) { 22 24 r.Get("/", rp.RepoTags) 23 25 r.Route("/{tag}", func(r chi.Router) { 24 - r.Use(middleware.AuthMiddleware(rp.oauth)) 25 - // require auth to download for now 26 26 r.Get("/download/{file}", rp.DownloadArtifact) 27 27 28 28 // require repo:push to upload or delete artifacts ··· 30 30 // additionally: only the uploader can truly delete an artifact 31 31 // (record+blob will live on their pds) 32 32 r.Group(func(r chi.Router) { 33 - r.With(mw.RepoPermissionMiddleware("repo:push")) 33 + r.Use(middleware.AuthMiddleware(rp.oauth)) 34 + r.Use(mw.RepoPermissionMiddleware("repo:push")) 34 35 r.Post("/upload", rp.AttachArtifact) 35 36 r.Delete("/{file}", rp.DeleteArtifact) 36 37 }) ··· 64 65 r.Get("/*", rp.RepoCompare) 65 66 }) 66 67 68 + // label panel in issues/pulls/discussions/tasks 69 + r.Route("/label", func(r chi.Router) { 70 + r.Get("/", rp.LabelPanel) 71 + r.Get("/edit", rp.EditLabelPanel) 72 + }) 73 + 67 74 // settings routes, needs auth 68 75 r.Group(func(r chi.Router) { 69 76 r.Use(middleware.AuthMiddleware(rp.oauth)) ··· 76 83 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 77 84 r.Get("/", rp.RepoSettings) 78 85 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 86 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) 87 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef) 88 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/subscribe", rp.SubscribeLabel) 89 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/unsubscribe", rp.UnsubscribeLabel) 79 90 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 80 91 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 81 92 r.Put("/branches/default", rp.SetDefaultBranch)
+14 -12
appview/reporesolver/resolver.go
··· 14 14 "github.com/bluesky-social/indigo/atproto/identity" 15 15 securejoin "github.com/cyphar/filepath-securejoin" 16 16 "github.com/go-chi/chi/v5" 17 - "tangled.sh/tangled.sh/core/appview/config" 18 - "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/oauth" 20 - "tangled.sh/tangled.sh/core/appview/pages" 21 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 22 - "tangled.sh/tangled.sh/core/idresolver" 23 - "tangled.sh/tangled.sh/core/rbac" 17 + "tangled.org/core/appview/config" 18 + "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/models" 20 + "tangled.org/core/appview/oauth" 21 + "tangled.org/core/appview/pages" 22 + "tangled.org/core/appview/pages/repoinfo" 23 + "tangled.org/core/idresolver" 24 + "tangled.org/core/rbac" 24 25 ) 25 26 26 27 type ResolvedRepo struct { 27 - db.Repo 28 + models.Repo 28 29 OwnerId identity.Identity 29 30 CurrentDir string 30 31 Ref string ··· 44 45 } 45 46 46 47 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 47 - repo, ok := r.Context().Value("repo").(*db.Repo) 48 + repo, ok := r.Context().Value("repo").(*models.Repo) 48 49 if !ok { 49 50 log.Println("malformed middleware: `repo` not exist in context") 50 51 return nil, fmt.Errorf("malformed middleware") ··· 162 163 log.Println("failed to get repo source for ", repoAt, err) 163 164 } 164 165 165 - var sourceRepo *db.Repo 166 + var sourceRepo *models.Repo 166 167 if source != "" { 167 168 sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) 168 169 if err != nil { ··· 184 185 OwnerDid: f.OwnerDid(), 185 186 OwnerHandle: f.OwnerHandle(), 186 187 Name: f.Name, 188 + Rkey: f.Repo.Rkey, 187 189 RepoAt: repoAt, 188 190 Description: f.Description, 189 191 IsStarred: isStarred, 190 192 Knot: knot, 191 193 Spindle: f.Spindle, 192 194 Roles: f.RolesInRepo(user), 193 - Stats: db.RepoStats{ 195 + Stats: models.RepoStats{ 194 196 StarCount: starCount, 195 197 IssueCount: issueCount, 196 198 PullCount: pullCount, ··· 210 212 func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 211 213 if u != nil { 212 214 r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 213 - return repoinfo.RolesInRepo{r} 215 + return repoinfo.RolesInRepo{Roles: r} 214 216 } else { 215 217 return repoinfo.RolesInRepo{} 216 218 }
+13 -29
appview/serververify/verify.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 7 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/rbac" 8 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/rbac" 14 13 ) 15 14 16 15 var ( ··· 24 23 scheme = "http" 25 24 } 26 25 27 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 - req, err := http.NewRequest("GET", url, nil) 29 - if err != nil { 30 - return "", err 31 - } 32 - 33 - client := &http.Client{ 34 - Timeout: 1 * time.Second, 35 - } 36 - 37 - resp, err := client.Do(req.WithContext(ctx)) 38 - if err != nil || resp.StatusCode != 200 { 39 - return "", fmt.Errorf("failed to fetch /owner") 26 + host := fmt.Sprintf("%s://%s", scheme, domain) 27 + xrpcc := &indigoxrpc.Client{ 28 + Host: host, 40 29 } 41 30 42 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 - if err != nil { 44 - return "", fmt.Errorf("failed to read /owner response: %w", err) 31 + res, err := tangled.Owner(ctx, xrpcc) 32 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 33 + return "", xrpcerr 45 34 } 46 35 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 36 + return res.Owner, nil 53 37 } 54 38 55 39 type OwnerMismatch struct { ··· 65 49 func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 66 50 observedOwner, err := fetchOwner(ctx, domain, dev) 67 51 if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 52 + return err 69 53 } 70 54 71 55 if observedOwner != expectedOwner {
+64 -12
appview/settings/settings.go
··· 11 11 "time" 12 12 13 13 "github.com/go-chi/chi/v5" 14 - "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/appview/config" 16 - "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/email" 18 - "tangled.sh/tangled.sh/core/appview/middleware" 19 - "tangled.sh/tangled.sh/core/appview/oauth" 20 - "tangled.sh/tangled.sh/core/appview/pages" 21 - "tangled.sh/tangled.sh/core/tid" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/config" 16 + "tangled.org/core/appview/db" 17 + "tangled.org/core/appview/email" 18 + "tangled.org/core/appview/middleware" 19 + "tangled.org/core/appview/models" 20 + "tangled.org/core/appview/oauth" 21 + "tangled.org/core/appview/pages" 22 + "tangled.org/core/tid" 22 23 23 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 25 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 40 41 {"Name": "profile", "Icon": "user"}, 41 42 {"Name": "keys", "Icon": "key"}, 42 43 {"Name": "emails", "Icon": "mail"}, 44 + {"Name": "notifications", "Icon": "bell"}, 43 45 } 44 46 ) 45 47 ··· 67 69 r.Post("/primary", s.emailsPrimary) 68 70 }) 69 71 72 + r.Route("/notifications", func(r chi.Router) { 73 + r.Get("/", s.notificationsSettings) 74 + r.Put("/", s.updateNotificationPreferences) 75 + }) 76 + 70 77 return r 71 78 } 72 79 ··· 80 87 }) 81 88 } 82 89 90 + func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) { 91 + user := s.OAuth.GetUser(r) 92 + did := s.OAuth.GetDid(r) 93 + 94 + prefs, err := s.Db.GetNotificationPreferences(r.Context(), did) 95 + if err != nil { 96 + log.Printf("failed to get notification preferences: %s", err) 97 + s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") 98 + return 99 + } 100 + 101 + s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{ 102 + LoggedInUser: user, 103 + Preferences: prefs, 104 + Tabs: settingsTabs, 105 + Tab: "notifications", 106 + }) 107 + } 108 + 109 + func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) { 110 + did := s.OAuth.GetDid(r) 111 + 112 + prefs := &models.NotificationPreferences{ 113 + UserDid: did, 114 + RepoStarred: r.FormValue("repo_starred") == "on", 115 + IssueCreated: r.FormValue("issue_created") == "on", 116 + IssueCommented: r.FormValue("issue_commented") == "on", 117 + IssueClosed: r.FormValue("issue_closed") == "on", 118 + PullCreated: r.FormValue("pull_created") == "on", 119 + PullCommented: r.FormValue("pull_commented") == "on", 120 + PullMerged: r.FormValue("pull_merged") == "on", 121 + Followed: r.FormValue("followed") == "on", 122 + EmailNotifications: r.FormValue("email_notifications") == "on", 123 + } 124 + 125 + err := s.Db.UpdateNotificationPreferences(r.Context(), prefs) 126 + if err != nil { 127 + log.Printf("failed to update notification preferences: %s", err) 128 + s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.") 129 + return 130 + } 131 + 132 + s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.") 133 + } 134 + 83 135 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 84 136 user := s.OAuth.GetUser(r) 85 137 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) ··· 185 237 } 186 238 defer tx.Rollback() 187 239 188 - if err := db.AddEmail(tx, db.Email{ 240 + if err := db.AddEmail(tx, models.Email{ 189 241 Did: did, 190 242 Address: emAddr, 191 243 Verified: false, ··· 246 298 if s.Config.Core.Dev { 247 299 appUrl = "http://" + s.Config.Core.ListenAddr 248 300 } else { 249 - appUrl = "https://tangled.sh" 301 + appUrl = s.Config.Core.AppviewHost 250 302 } 251 303 252 304 return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code)) ··· 418 470 } 419 471 420 472 // store in pds too 421 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 473 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 422 474 Collection: tangled.PublicKeyNSID, 423 475 Repo: did, 424 476 Rkey: rkey, ··· 475 527 476 528 if rkey != "" { 477 529 // remove from pds too 478 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 530 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 479 531 Collection: tangled.PublicKeyNSID, 480 532 Repo: did, 481 533 Rkey: rkey,
+75 -12
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "encoding/json" 6 + "errors" 5 7 "fmt" 6 8 "log/slog" 7 9 "net/http" 10 + "net/url" 8 11 "os" 9 12 "strings" 10 13 11 14 "github.com/go-chi/chi/v5" 12 15 "github.com/posthog/posthog-go" 13 - "tangled.sh/tangled.sh/core/appview/config" 14 - "tangled.sh/tangled.sh/core/appview/db" 15 - "tangled.sh/tangled.sh/core/appview/dns" 16 - "tangled.sh/tangled.sh/core/appview/email" 17 - "tangled.sh/tangled.sh/core/appview/pages" 18 - "tangled.sh/tangled.sh/core/appview/state/userutil" 19 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 - "tangled.sh/tangled.sh/core/idresolver" 16 + "tangled.org/core/appview/config" 17 + "tangled.org/core/appview/db" 18 + "tangled.org/core/appview/dns" 19 + "tangled.org/core/appview/email" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/pages" 22 + "tangled.org/core/appview/state/userutil" 23 + "tangled.org/core/idresolver" 21 24 ) 22 25 23 26 type Signup struct { ··· 25 28 db *db.DB 26 29 cf *dns.Cloudflare 27 30 posthog posthog.Client 28 - xrpc *xrpcclient.Client 29 31 idResolver *idresolver.Resolver 30 32 pages *pages.Pages 31 33 l *slog.Logger ··· 115 117 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 118 switch r.Method { 117 119 case http.MethodGet: 118 - s.pages.Signup(w) 120 + s.pages.Signup(w, pages.SignupParams{ 121 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 122 + }) 119 123 case http.MethodPost: 120 124 if s.cf == nil { 121 125 http.Error(w, "signup is disabled", http.StatusFailedDependency) 126 + return 122 127 } 123 128 emailId := r.FormValue("email") 129 + cfToken := r.FormValue("cf-turnstile-response") 124 130 125 131 noticeId := "signup-msg" 132 + 133 + if err := s.validateCaptcha(cfToken, r); err != nil { 134 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 135 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 136 + return 137 + } 138 + 126 139 if !email.IsValidEmail(emailId) { 127 140 s.pages.Notice(w, noticeId, "Invalid email address.") 128 141 return ··· 163 176 s.pages.Notice(w, noticeId, "Failed to send email.") 164 177 return 165 178 } 166 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 179 + err = db.AddInflightSignup(s.db, models.InflightSignup{ 167 180 Email: emailId, 168 181 InviteCode: code, 169 182 }) ··· 229 242 return 230 243 } 231 244 232 - err = db.AddEmail(s.db, db.Email{ 245 + err = db.AddEmail(s.db, models.Email{ 233 246 Did: did, 234 247 Address: email, 235 248 Verified: true, ··· 254 267 return 255 268 } 256 269 } 270 + 271 + type turnstileResponse struct { 272 + Success bool `json:"success"` 273 + ErrorCodes []string `json:"error-codes,omitempty"` 274 + ChallengeTs string `json:"challenge_ts,omitempty"` 275 + Hostname string `json:"hostname,omitempty"` 276 + } 277 + 278 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 279 + if cfToken == "" { 280 + return errors.New("captcha token is empty") 281 + } 282 + 283 + if s.config.Cloudflare.TurnstileSecretKey == "" { 284 + return errors.New("turnstile secret key not configured") 285 + } 286 + 287 + data := url.Values{} 288 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 289 + data.Set("response", cfToken) 290 + 291 + // include the client IP if we have it 292 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 293 + data.Set("remoteip", remoteIP) 294 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 295 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 296 + data.Set("remoteip", strings.TrimSpace(ips[0])) 297 + } 298 + } else { 299 + data.Set("remoteip", r.RemoteAddr) 300 + } 301 + 302 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 303 + if err != nil { 304 + return fmt.Errorf("failed to verify turnstile token: %w", err) 305 + } 306 + defer resp.Body.Close() 307 + 308 + var turnstileResp turnstileResponse 309 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 310 + return fmt.Errorf("failed to decode turnstile response: %w", err) 311 + } 312 + 313 + if !turnstileResp.Success { 314 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 315 + return errors.New("turnstile validation failed") 316 + } 317 + 318 + return nil 319 + }
+23 -21
appview/spindles/spindles.go
··· 9 9 "time" 10 10 11 11 "github.com/go-chi/chi/v5" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview/config" 14 - "tangled.sh/tangled.sh/core/appview/db" 15 - "tangled.sh/tangled.sh/core/appview/middleware" 16 - "tangled.sh/tangled.sh/core/appview/oauth" 17 - "tangled.sh/tangled.sh/core/appview/pages" 18 - "tangled.sh/tangled.sh/core/appview/serververify" 19 - "tangled.sh/tangled.sh/core/idresolver" 20 - "tangled.sh/tangled.sh/core/rbac" 21 - "tangled.sh/tangled.sh/core/tid" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/config" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/middleware" 16 + "tangled.org/core/appview/models" 17 + "tangled.org/core/appview/oauth" 18 + "tangled.org/core/appview/pages" 19 + "tangled.org/core/appview/serververify" 20 + "tangled.org/core/appview/xrpcclient" 21 + "tangled.org/core/idresolver" 22 + "tangled.org/core/rbac" 23 + "tangled.org/core/tid" 22 24 23 25 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 26 "github.com/bluesky-social/indigo/atproto/syntax" ··· 114 116 } 115 117 116 118 // organize repos by did 117 - repoMap := make(map[string][]db.Repo) 119 + repoMap := make(map[string][]models.Repo) 118 120 for _, r := range repos { 119 121 repoMap[r.Did] = append(repoMap[r.Did], r) 120 122 } ··· 162 164 s.Enforcer.E.LoadPolicy() 163 165 }() 164 166 165 - err = db.AddSpindle(tx, db.Spindle{ 167 + err = db.AddSpindle(tx, models.Spindle{ 166 168 Owner: syntax.DID(user.Did), 167 169 Instance: instance, 168 170 }) ··· 187 189 return 188 190 } 189 191 190 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 191 193 var exCid *string 192 194 if ex != nil { 193 195 exCid = ex.Cid 194 196 } 195 197 196 198 // re-announce by registering under same rkey 197 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 198 200 Collection: tangled.SpindleNSID, 199 201 Repo: user.Did, 200 202 Rkey: instance, ··· 330 332 return 331 333 } 332 334 333 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 334 336 Collection: tangled.SpindleNSID, 335 337 Repo: user.Did, 336 338 Rkey: instance, ··· 404 406 if err != nil { 405 407 l.Error("verification failed", "err", err) 406 408 407 - if errors.Is(err, serververify.FetchError) { 408 - s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 409 + if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 410 + s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!") 409 411 return 410 412 } 411 413 ··· 442 444 } 443 445 444 446 w.Header().Set("HX-Reswap", "outerHTML") 445 - s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]}) 447 + s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 446 448 } 447 449 448 450 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { ··· 523 525 rkey := tid.TID() 524 526 525 527 // add member to db 526 - if err = db.AddSpindleMember(tx, db.SpindleMember{ 528 + if err = db.AddSpindleMember(tx, models.SpindleMember{ 527 529 Did: syntax.DID(user.Did), 528 530 Rkey: rkey, 529 531 Instance: instance, ··· 540 542 return 541 543 } 542 544 543 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 544 546 Collection: tangled.SpindleMemberNSID, 545 547 Repo: user.Did, 546 548 Rkey: rkey, ··· 681 683 } 682 684 683 685 // remove from pds 684 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 685 687 Collection: tangled.SpindleMemberNSID, 686 688 Repo: user.Did, 687 689 Rkey: members[0].Rkey,
+10 -9
appview/state/follow.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - "tangled.sh/tangled.sh/core/api/tangled" 11 - "tangled.sh/tangled.sh/core/appview/db" 12 - "tangled.sh/tangled.sh/core/appview/pages" 13 - "tangled.sh/tangled.sh/core/tid" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/tid" 14 15 ) 15 16 16 17 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 42 43 case http.MethodPost: 43 44 createdAt := time.Now().Format(time.RFC3339) 44 45 rkey := tid.TID() 45 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 46 47 Collection: tangled.GraphFollowNSID, 47 48 Repo: currentUser.Did, 48 49 Rkey: rkey, ··· 59 60 60 61 log.Println("created atproto record: ", resp.Uri) 61 62 62 - follow := &db.Follow{ 63 + follow := &models.Follow{ 63 64 UserDid: currentUser.Did, 64 65 SubjectDid: subjectIdent.DID.String(), 65 66 Rkey: rkey, ··· 75 76 76 77 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 77 78 UserDid: subjectIdent.DID.String(), 78 - FollowStatus: db.IsFollowing, 79 + FollowStatus: models.IsFollowing, 79 80 }) 80 81 81 82 return ··· 87 88 return 88 89 } 89 90 90 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 91 92 Collection: tangled.GraphFollowNSID, 92 93 Repo: currentUser.Did, 93 94 Rkey: follow.Rkey, ··· 106 107 107 108 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 108 109 UserDid: subjectIdent.DID.String(), 109 - FollowStatus: db.IsNotFollowing, 110 + FollowStatus: models.IsNotFollowing, 110 111 }) 111 112 112 113 s.notifier.DeleteFollow(r.Context(), follow)
+151
appview/state/gfi.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "sort" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + "tangled.org/core/consts" 16 + ) 17 + 18 + func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 + user := s.oauth.GetUser(r) 20 + 21 + page, ok := r.Context().Value("page").(pagination.Page) 22 + if !ok { 23 + page = pagination.FirstPage() 24 + } 25 + 26 + goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 + 28 + repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 + if err != nil { 30 + log.Println("failed to get repo labels", err) 31 + s.pages.Error503(w) 32 + return 33 + } 34 + 35 + if len(repoLabels) == 0 { 36 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 37 + LoggedInUser: user, 38 + RepoGroups: []*models.RepoGroup{}, 39 + LabelDefs: make(map[string]*models.LabelDefinition), 40 + Page: page, 41 + }) 42 + return 43 + } 44 + 45 + repoUris := make([]string, 0, len(repoLabels)) 46 + for _, rl := range repoLabels { 47 + repoUris = append(repoUris, rl.RepoAt.String()) 48 + } 49 + 50 + allIssues, err := db.GetIssuesPaginated( 51 + s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 55 + db.FilterIn("repo_at", repoUris), 56 + db.FilterEq("open", 1), 57 + ) 58 + if err != nil { 59 + log.Println("failed to get issues", err) 60 + s.pages.Error503(w) 61 + return 62 + } 63 + 64 + var goodFirstIssues []models.Issue 65 + for _, issue := range allIssues { 66 + if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 67 + goodFirstIssues = append(goodFirstIssues, issue) 68 + } 69 + } 70 + 71 + repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 72 + for _, issue := range goodFirstIssues { 73 + if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 74 + group.Issues = append(group.Issues, issue) 75 + } else { 76 + repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 77 + Repo: issue.Repo, 78 + Issues: []models.Issue{issue}, 79 + } 80 + } 81 + } 82 + 83 + var sortedGroups []*models.RepoGroup 84 + for _, group := range repoGroups { 85 + sortedGroups = append(sortedGroups, group) 86 + } 87 + 88 + sort.Slice(sortedGroups, func(i, j int) bool { 89 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 90 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 91 + 92 + // If one is tangled and the other isn't, non-tangled comes first 93 + if iIsTangled != jIsTangled { 94 + return jIsTangled // true if j is tangled (i should come first) 95 + } 96 + 97 + // Both tangled or both not tangled: sort by name 98 + return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 99 + }) 100 + 101 + groupStart := page.Offset 102 + groupEnd := page.Offset + page.Limit 103 + if groupStart > len(sortedGroups) { 104 + groupStart = len(sortedGroups) 105 + } 106 + if groupEnd > len(sortedGroups) { 107 + groupEnd = len(sortedGroups) 108 + } 109 + 110 + paginatedGroups := sortedGroups[groupStart:groupEnd] 111 + 112 + var allIssuesFromGroups []models.Issue 113 + for _, group := range paginatedGroups { 114 + allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 115 + } 116 + 117 + var allLabelDefs []models.LabelDefinition 118 + if len(allIssuesFromGroups) > 0 { 119 + labelDefUris := make(map[string]bool) 120 + for _, issue := range allIssuesFromGroups { 121 + for labelDefUri := range issue.Labels.Inner() { 122 + labelDefUris[labelDefUri] = true 123 + } 124 + } 125 + 126 + uriList := make([]string, 0, len(labelDefUris)) 127 + for uri := range labelDefUris { 128 + uriList = append(uriList, uri) 129 + } 130 + 131 + if len(uriList) > 0 { 132 + allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 133 + if err != nil { 134 + log.Println("failed to fetch labels", err) 135 + } 136 + } 137 + } 138 + 139 + labelDefsMap := make(map[string]*models.LabelDefinition) 140 + for i := range allLabelDefs { 141 + labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 142 + } 143 + 144 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 145 + LoggedInUser: user, 146 + RepoGroups: paginatedGroups, 147 + LabelDefs: labelDefsMap, 148 + Page: page, 149 + GfiLabel: labelDefsMap[goodFirstIssueLabel], 150 + }) 151 + }
+4 -4
appview/state/git_http.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/identity" 10 10 "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.org/core/appview/models" 12 12 ) 13 13 14 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 - repo := r.Context().Value("repo").(*db.Repo) 16 + repo := r.Context().Value("repo").(*models.Repo) 17 17 18 18 scheme := "https" 19 19 if s.config.Core.Dev { ··· 31 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 32 return 33 33 } 34 - repo := r.Context().Value("repo").(*db.Repo) 34 + repo := r.Context().Value("repo").(*models.Repo) 35 35 36 36 scheme := "https" 37 37 if s.config.Core.Dev { ··· 48 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 49 return 50 50 } 51 - repo := r.Context().Value("repo").(*db.Repo) 51 + repo := r.Context().Value("repo").(*models.Repo) 52 52 53 53 scheme := "https" 54 54 if s.config.Core.Dev {
+29 -15
appview/state/knotstream.go
··· 8 8 "slices" 9 9 "time" 10 10 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview/cache" 13 - "tangled.sh/tangled.sh/core/appview/config" 14 - "tangled.sh/tangled.sh/core/appview/db" 15 - ec "tangled.sh/tangled.sh/core/eventconsumer" 16 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 17 - "tangled.sh/tangled.sh/core/log" 18 - "tangled.sh/tangled.sh/core/rbac" 19 - "tangled.sh/tangled.sh/core/workflow" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/cache" 13 + "tangled.org/core/appview/config" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/models" 16 + ec "tangled.org/core/eventconsumer" 17 + "tangled.org/core/eventconsumer/cursor" 18 + "tangled.org/core/log" 19 + "tangled.org/core/rbac" 20 + "tangled.org/core/workflow" 20 21 21 22 "github.com/bluesky-social/indigo/atproto/syntax" 22 23 "github.com/go-git/go-git/v5/plumbing" ··· 124 125 } 125 126 } 126 127 127 - punch := db.Punch{ 128 + punch := models.Punch{ 128 129 Did: record.CommitterDid, 129 130 Date: time.Now(), 130 131 Count: count, ··· 156 157 return fmt.Errorf("%s is not a valid reference name", ref) 157 158 } 158 159 159 - var langs []db.RepoLanguage 160 + var langs []models.RepoLanguage 160 161 for _, l := range record.Meta.LangBreakdown.Inputs { 161 162 if l == nil { 162 163 continue 163 164 } 164 165 165 - langs = append(langs, db.RepoLanguage{ 166 + langs = append(langs, models.RepoLanguage{ 166 167 RepoAt: repo.RepoAt(), 167 168 Ref: ref.Short(), 168 169 IsDefaultRef: record.Meta.IsDefaultRef, ··· 171 172 }) 172 173 } 173 174 174 - return db.InsertRepoLanguages(d, langs) 175 + tx, err := d.Begin() 176 + if err != nil { 177 + return err 178 + } 179 + defer tx.Rollback() 180 + 181 + // update appview's cache 182 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 183 + if err != nil { 184 + fmt.Printf("failed; %s\n", err) 185 + // non-fatal 186 + } 187 + 188 + return tx.Commit() 175 189 } 176 190 177 191 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { ··· 207 221 } 208 222 209 223 // trigger info 210 - var trigger db.Trigger 224 + var trigger models.Trigger 211 225 var sha string 212 226 trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 213 227 switch trigger.Kind { ··· 234 248 return fmt.Errorf("failed to add trigger entry: %w", err) 235 249 } 236 250 237 - pipeline := db.Pipeline{ 251 + pipeline := models.Pipeline{ 238 252 Rkey: msg.Rkey, 239 253 Knot: source.Key(), 240 254 RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
+63
appview/state/login.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strings" 8 + 9 + "tangled.org/core/appview/pages" 10 + ) 11 + 12 + func (s *State) Login(w http.ResponseWriter, r *http.Request) { 13 + switch r.Method { 14 + case http.MethodGet: 15 + returnURL := r.URL.Query().Get("return_url") 16 + s.pages.Login(w, pages.LoginParams{ 17 + ReturnUrl: returnURL, 18 + }) 19 + case http.MethodPost: 20 + handle := r.FormValue("handle") 21 + 22 + // when users copy their handle from bsky.app, it tends to have these characters around it: 23 + // 24 + // @nelind.dk: 25 + // \u202a ensures that the handle is always rendered left to right and 26 + // \u202c reverts that so the rest of the page renders however it should 27 + handle = strings.TrimPrefix(handle, "\u202a") 28 + handle = strings.TrimSuffix(handle, "\u202c") 29 + 30 + // `@` is harmless 31 + handle = strings.TrimPrefix(handle, "@") 32 + 33 + // basic handle validation 34 + if !strings.Contains(handle, ".") { 35 + log.Println("invalid handle format", "raw", handle) 36 + s.pages.Notice( 37 + w, 38 + "login-msg", 39 + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 40 + ) 41 + return 42 + } 43 + 44 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 45 + if err != nil { 46 + http.Error(w, err.Error(), http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + s.pages.HxRedirect(w, redirectURL) 51 + } 52 + } 53 + 54 + func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 55 + err := s.oauth.DeleteSession(w, r) 56 + if err != nil { 57 + log.Println("failed to logout", "err", err) 58 + } else { 59 + log.Println("logged out successfully") 60 + } 61 + 62 + s.pages.HxRedirect(w, "/login") 63 + }
+213 -160
appview/state/profile.go
··· 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 16 "github.com/go-chi/chi/v5" 17 17 "github.com/gorilla/feeds" 18 - "tangled.sh/tangled.sh/core/api/tangled" 19 - "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/oauth" 21 - "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.org/core/api/tangled" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/pages" 22 22 ) 23 23 24 24 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 25 25 tabVal := r.URL.Query().Get("tab") 26 26 switch tabVal { 27 - case "": 28 - s.profileHomePage(w, r) 29 27 case "repos": 30 28 s.reposPage(w, r) 31 29 case "followers": 32 30 s.followersPage(w, r) 33 31 case "following": 34 32 s.followingPage(w, r) 33 + case "starred": 34 + s.starredPage(w, r) 35 + case "strings": 36 + s.stringsPage(w, r) 37 + default: 38 + s.profileOverview(w, r) 35 39 } 36 40 } 37 41 38 - type ProfilePageParams struct { 39 - Id identity.Identity 40 - LoggedInUser *oauth.User 41 - Card pages.ProfileCard 42 - } 43 - 44 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams { 42 + func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 45 43 didOrHandle := chi.URLParam(r, "user") 46 44 if didOrHandle == "" { 47 - http.Error(w, "bad request", http.StatusBadRequest) 48 - return nil 45 + return nil, fmt.Errorf("empty DID or handle") 49 46 } 50 47 51 48 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 52 49 if !ok { 53 - log.Printf("malformed middleware") 54 - w.WriteHeader(http.StatusInternalServerError) 55 - return nil 50 + return nil, fmt.Errorf("failed to resolve ID") 56 51 } 57 52 did := ident.DID.String() 58 53 59 54 profile, err := db.GetProfile(s.db, did) 60 55 if err != nil { 61 - log.Printf("getting profile data for %s: %s", did, err) 62 - s.pages.Error500(w) 63 - return nil 56 + return nil, fmt.Errorf("failed to get profile: %w", err) 57 + } 58 + 59 + repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 60 + if err != nil { 61 + return nil, fmt.Errorf("failed to get repo count: %w", err) 62 + } 63 + 64 + stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 65 + if err != nil { 66 + return nil, fmt.Errorf("failed to get string count: %w", err) 67 + } 68 + 69 + starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 70 + if err != nil { 71 + return nil, fmt.Errorf("failed to get starred repo count: %w", err) 64 72 } 65 73 66 74 followStats, err := db.GetFollowerFollowingCount(s.db, did) 67 75 if err != nil { 68 - log.Printf("getting follow stats for %s: %s", did, err) 76 + return nil, fmt.Errorf("failed to get follower stats: %w", err) 69 77 } 70 78 71 79 loggedInUser := s.oauth.GetUser(r) 72 - followStatus := db.IsNotFollowing 80 + followStatus := models.IsNotFollowing 73 81 if loggedInUser != nil { 74 82 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 75 83 } 76 84 77 - return &ProfilePageParams{ 78 - Id: ident, 79 - LoggedInUser: loggedInUser, 80 - Card: pages.ProfileCard{ 81 - UserDid: did, 82 - UserHandle: ident.Handle.String(), 83 - Profile: profile, 84 - FollowStatus: followStatus, 85 + now := time.Now() 86 + startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 87 + punchcard, err := db.MakePunchcard( 88 + s.db, 89 + db.FilterEq("did", did), 90 + db.FilterGte("date", startOfYear.Format(time.DateOnly)), 91 + db.FilterLte("date", now.Format(time.DateOnly)), 92 + ) 93 + if err != nil { 94 + return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 95 + } 96 + 97 + return &pages.ProfileCard{ 98 + UserDid: did, 99 + UserHandle: ident.Handle.String(), 100 + Profile: profile, 101 + FollowStatus: followStatus, 102 + Stats: pages.ProfileStats{ 103 + RepoCount: repoCount, 104 + StringCount: stringCount, 105 + StarredCount: starredCount, 85 106 FollowersCount: followStats.Followers, 86 107 FollowingCount: followStats.Following, 87 108 }, 88 - } 109 + Punchcard: punchcard, 110 + }, nil 89 111 } 90 112 91 - func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) { 92 - pageWithProfile := s.profilePage(w, r) 93 - if pageWithProfile == nil { 113 + func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) { 114 + l := s.logger.With("handler", "profileHomePage") 115 + 116 + profile, err := s.profile(r) 117 + if err != nil { 118 + l.Error("failed to build profile card", "err", err) 119 + s.pages.Error500(w) 94 120 return 95 121 } 122 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 96 123 97 - id := pageWithProfile.Id 98 124 repos, err := db.GetRepos( 99 125 s.db, 100 126 0, 101 - db.FilterEq("did", id.DID), 127 + db.FilterEq("did", profile.UserDid), 102 128 ) 103 129 if err != nil { 104 - log.Printf("getting repos for %s: %s", id.DID, err) 130 + l.Error("failed to fetch repos", "err", err) 105 131 } 106 132 107 - profile := pageWithProfile.Card.Profile 108 133 // filter out ones that are pinned 109 - pinnedRepos := []db.Repo{} 134 + pinnedRepos := []models.Repo{} 110 135 for i, r := range repos { 111 136 // if this is a pinned repo, add it 112 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 137 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 113 138 pinnedRepos = append(pinnedRepos, r) 114 139 } 115 140 116 141 // if there are no saved pins, add the first 4 repos 117 - if profile.IsPinnedReposEmpty() && i < 4 { 142 + if profile.Profile.IsPinnedReposEmpty() && i < 4 { 118 143 pinnedRepos = append(pinnedRepos, r) 119 144 } 120 145 } 121 146 122 - collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 147 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 123 148 if err != nil { 124 - log.Printf("getting collaborating repos for %s: %s", id.DID, err) 149 + l.Error("failed to fetch collaborating repos", "err", err) 125 150 } 126 151 127 - pinnedCollaboratingRepos := []db.Repo{} 152 + pinnedCollaboratingRepos := []models.Repo{} 128 153 for _, r := range collaboratingRepos { 129 154 // if this is a pinned repo, add it 130 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 155 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 131 156 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 132 157 } 133 158 } 134 159 135 - timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 160 + timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 136 161 if err != nil { 137 - log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 162 + l.Error("failed to create timeline", "err", err) 138 163 } 139 164 140 - var didsToResolve []string 141 - for _, r := range collaboratingRepos { 142 - didsToResolve = append(didsToResolve, r.Did) 143 - } 144 - for _, byMonth := range timeline.ByMonth { 145 - for _, pe := range byMonth.PullEvents.Items { 146 - didsToResolve = append(didsToResolve, pe.Repo.Did) 147 - } 148 - for _, ie := range byMonth.IssueEvents.Items { 149 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 150 - } 151 - for _, re := range byMonth.RepoEvents { 152 - didsToResolve = append(didsToResolve, re.Repo.Did) 153 - if re.Source != nil { 154 - didsToResolve = append(didsToResolve, re.Source.Did) 155 - } 156 - } 157 - } 158 - 159 - now := time.Now() 160 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 161 - punchcard, err := db.MakePunchcard( 162 - s.db, 163 - db.FilterEq("did", id.DID), 164 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 165 - db.FilterLte("date", now.Format(time.DateOnly)), 166 - ) 167 - if err != nil { 168 - log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 169 - } 170 - 171 - s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{ 172 - LoggedInUser: pageWithProfile.LoggedInUser, 165 + s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 166 + LoggedInUser: s.oauth.GetUser(r), 167 + Card: profile, 173 168 Repos: pinnedRepos, 174 169 CollaboratingRepos: pinnedCollaboratingRepos, 175 - Card: pageWithProfile.Card, 176 - Punchcard: punchcard, 177 170 ProfileTimeline: timeline, 178 171 }) 179 172 } 180 173 181 174 func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 182 - pageWithProfile := s.profilePage(w, r) 183 - if pageWithProfile == nil { 175 + l := s.logger.With("handler", "reposPage") 176 + 177 + profile, err := s.profile(r) 178 + if err != nil { 179 + l.Error("failed to build profile card", "err", err) 180 + s.pages.Error500(w) 184 181 return 185 182 } 183 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 186 184 187 - id := pageWithProfile.Id 188 185 repos, err := db.GetRepos( 189 186 s.db, 190 187 0, 191 - db.FilterEq("did", id.DID), 188 + db.FilterEq("did", profile.UserDid), 192 189 ) 193 190 if err != nil { 194 - log.Printf("getting repos for %s: %s", id.DID, err) 191 + l.Error("failed to get repos", "err", err) 192 + s.pages.Error500(w) 193 + return 194 + } 195 + 196 + err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 197 + LoggedInUser: s.oauth.GetUser(r), 198 + Repos: repos, 199 + Card: profile, 200 + }) 201 + } 202 + 203 + func (s *State) starredPage(w http.ResponseWriter, r *http.Request) { 204 + l := s.logger.With("handler", "starredPage") 205 + 206 + profile, err := s.profile(r) 207 + if err != nil { 208 + l.Error("failed to build profile card", "err", err) 209 + s.pages.Error500(w) 210 + return 195 211 } 212 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 196 213 197 - s.pages.ReposPage(w, pages.ReposPageParams{ 198 - LoggedInUser: pageWithProfile.LoggedInUser, 214 + stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 215 + if err != nil { 216 + l.Error("failed to get stars", "err", err) 217 + s.pages.Error500(w) 218 + return 219 + } 220 + var repos []models.Repo 221 + for _, s := range stars { 222 + if s.Repo != nil { 223 + repos = append(repos, *s.Repo) 224 + } 225 + } 226 + 227 + err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 228 + LoggedInUser: s.oauth.GetUser(r), 199 229 Repos: repos, 200 - Card: pageWithProfile.Card, 230 + Card: profile, 231 + }) 232 + } 233 + 234 + func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) { 235 + l := s.logger.With("handler", "stringsPage") 236 + 237 + profile, err := s.profile(r) 238 + if err != nil { 239 + l.Error("failed to build profile card", "err", err) 240 + s.pages.Error500(w) 241 + return 242 + } 243 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 244 + 245 + strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 246 + if err != nil { 247 + l.Error("failed to get strings", "err", err) 248 + s.pages.Error500(w) 249 + return 250 + } 251 + 252 + err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 253 + LoggedInUser: s.oauth.GetUser(r), 254 + Strings: strings, 255 + Card: profile, 201 256 }) 202 257 } 203 258 204 259 type FollowsPageParams struct { 205 - LoggedInUser *oauth.User 206 - Follows []pages.FollowCard 207 - Card pages.ProfileCard 260 + Follows []pages.FollowCard 261 + Card *pages.ProfileCard 208 262 } 209 263 210 - func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) { 211 - pageWithProfile := s.profilePage(w, r) 212 - if pageWithProfile == nil { 213 - return FollowsPageParams{}, nil 264 + func (s *State) followPage( 265 + r *http.Request, 266 + fetchFollows func(db.Execer, string) ([]models.Follow, error), 267 + extractDid func(models.Follow) string, 268 + ) (*FollowsPageParams, error) { 269 + l := s.logger.With("handler", "reposPage") 270 + 271 + profile, err := s.profile(r) 272 + if err != nil { 273 + return nil, err 214 274 } 275 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 215 276 216 - id := pageWithProfile.Id 217 - loggedInUser := pageWithProfile.LoggedInUser 277 + loggedInUser := s.oauth.GetUser(r) 278 + params := FollowsPageParams{ 279 + Card: profile, 280 + } 218 281 219 - follows, err := fetchFollows(s.db, id.DID.String()) 282 + follows, err := fetchFollows(s.db, profile.UserDid) 220 283 if err != nil { 221 - log.Printf("getting followers for %s: %s", id.DID, err) 222 - return FollowsPageParams{}, err 284 + l.Error("failed to fetch follows", "err", err) 285 + return &params, err 223 286 } 224 287 225 288 if len(follows) == 0 { 226 - return FollowsPageParams{ 227 - LoggedInUser: loggedInUser, 228 - Follows: []pages.FollowCard{}, 229 - Card: pageWithProfile.Card, 230 - }, nil 289 + return &params, nil 231 290 } 232 291 233 292 followDids := make([]string, 0, len(follows)) ··· 237 296 238 297 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 239 298 if err != nil { 240 - log.Printf("getting profile for %s: %s", followDids, err) 241 - return FollowsPageParams{}, err 299 + l.Error("failed to get profiles", "followDids", followDids, "err", err) 300 + return &params, err 242 301 } 243 302 244 303 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) ··· 246 305 log.Printf("getting follow counts for %s: %s", followDids, err) 247 306 } 248 307 249 - var loggedInUserFollowing map[string]struct{} 308 + loggedInUserFollowing := make(map[string]struct{}) 250 309 if loggedInUser != nil { 251 310 following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 311 if err != nil { 253 - return FollowsPageParams{}, err 312 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 313 + return &params, err 254 314 } 255 - if len(following) > 0 { 256 - loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 - for _, follow := range following { 258 - loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 - } 315 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 316 + for _, follow := range following { 317 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 260 318 } 261 319 } 262 320 263 - followCards := make([]pages.FollowCard, 0, len(follows)) 264 - for _, did := range followDids { 265 - followStats, exists := followStatsMap[did] 266 - if !exists { 267 - followStats = db.FollowStats{} 321 + followCards := make([]pages.FollowCard, len(follows)) 322 + for i, did := range followDids { 323 + followStats := followStatsMap[did] 324 + followStatus := models.IsNotFollowing 325 + if _, exists := loggedInUserFollowing[did]; exists { 326 + followStatus = models.IsFollowing 327 + } else if loggedInUser != nil && loggedInUser.Did == did { 328 + followStatus = models.IsSelf 268 329 } 269 - followStatus := db.IsNotFollowing 270 - if loggedInUserFollowing != nil { 271 - if _, exists := loggedInUserFollowing[did]; exists { 272 - followStatus = db.IsFollowing 273 - } else if loggedInUser.Did == did { 274 - followStatus = db.IsSelf 275 - } 276 - } 277 - var profile *db.Profile 330 + 331 + var profile *models.Profile 278 332 if p, exists := profiles[did]; exists { 279 333 profile = p 280 334 } else { 281 - profile = &db.Profile{} 335 + profile = &models.Profile{} 282 336 profile.Did = did 283 337 } 284 - followCards = append(followCards, pages.FollowCard{ 338 + followCards[i] = pages.FollowCard{ 339 + LoggedInUser: loggedInUser, 285 340 UserDid: did, 286 341 FollowStatus: followStatus, 287 342 FollowersCount: followStats.Followers, 288 343 FollowingCount: followStats.Following, 289 344 Profile: profile, 290 - }) 345 + } 291 346 } 292 347 293 - return FollowsPageParams{ 294 - LoggedInUser: loggedInUser, 295 - Follows: followCards, 296 - Card: pageWithProfile.Card, 297 - }, nil 348 + params.Follows = followCards 349 + 350 + return &params, nil 298 351 } 299 352 300 353 func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 301 - followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 354 + followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid }) 302 355 if err != nil { 303 356 s.pages.Notice(w, "all-followers", "Failed to load followers") 304 357 return 305 358 } 306 359 307 - s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 - LoggedInUser: followPage.LoggedInUser, 360 + s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 361 + LoggedInUser: s.oauth.GetUser(r), 309 362 Followers: followPage.Follows, 310 363 Card: followPage.Card, 311 364 }) 312 365 } 313 366 314 367 func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 315 - followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 368 + followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid }) 316 369 if err != nil { 317 370 s.pages.Notice(w, "all-following", "Failed to load following") 318 371 return 319 372 } 320 373 321 - s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 - LoggedInUser: followPage.LoggedInUser, 374 + s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 375 + LoggedInUser: s.oauth.GetUser(r), 323 376 Following: followPage.Follows, 324 377 Card: followPage.Card, 325 378 }) ··· 393 446 return &feed, nil 394 447 } 395 448 396 - func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 449 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error { 397 450 for _, pull := range pulls { 398 451 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 399 452 if err != nil { ··· 406 459 return nil 407 460 } 408 461 409 - func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 462 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error { 410 463 for _, issue := range issues { 411 - owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 464 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 412 465 if err != nil { 413 466 return err 414 467 } ··· 418 471 return nil 419 472 } 420 473 421 - func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 474 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error { 422 475 for _, repo := range repos { 423 476 item, err := s.createRepoItem(ctx, repo, author) 424 477 if err != nil { ··· 429 482 return nil 430 483 } 431 484 432 - func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 485 + func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 433 486 return &feeds.Item{ 434 487 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 435 488 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, ··· 438 491 } 439 492 } 440 493 441 - func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 494 + func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 442 495 return &feeds.Item{ 443 - Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 444 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 496 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 497 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 445 498 Created: issue.Created, 446 499 Author: author, 447 500 } 448 501 } 449 502 450 - func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 503 + func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 451 504 var title string 452 505 if repo.Source != nil { 453 506 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) ··· 498 551 stat1 := r.FormValue("stat1") 499 552 500 553 if stat0 != "" { 501 - profile.Stats[0].Kind = db.VanityStatKind(stat0) 554 + profile.Stats[0].Kind = models.VanityStatKind(stat0) 502 555 } 503 556 504 557 if stat1 != "" { 505 - profile.Stats[1].Kind = db.VanityStatKind(stat1) 558 + profile.Stats[1].Kind = models.VanityStatKind(stat1) 506 559 } 507 560 508 561 if err := db.ValidateProfile(s.db, profile); err != nil { ··· 553 606 s.updateProfile(profile, w, r) 554 607 } 555 608 556 - func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 609 + func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 557 610 user := s.oauth.GetUser(r) 558 611 tx, err := s.db.BeginTx(r.Context(), nil) 559 612 if err != nil { ··· 581 634 vanityStats = append(vanityStats, string(v.Kind)) 582 635 } 583 636 584 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 585 638 var cid *string 586 639 if ex != nil { 587 640 cid = ex.Cid 588 641 } 589 642 590 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 591 644 Collection: tangled.ActorProfileNSID, 592 645 Repo: user.Did, 593 646 Rkey: "self", ··· 642 695 log.Printf("getting profile data for %s: %s", user.Did, err) 643 696 } 644 697 645 - repos, err := db.GetAllReposByDid(s.db, user.Did) 698 + repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 646 699 if err != nil { 647 700 log.Printf("getting repos for %s: %s", user.Did, err) 648 701 }
+17 -14
appview/state/reaction.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 10 lexutil "github.com/bluesky-social/indigo/lex/util" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/pages" 15 - "tangled.sh/tangled.sh/core/tid" 11 + 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/models" 15 + "tangled.org/core/appview/pages" 16 + "tangled.org/core/tid" 16 17 ) 17 18 18 19 func (s *State) React(w http.ResponseWriter, r *http.Request) { ··· 30 31 return 31 32 } 32 33 33 - reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind")) 34 + reactionKind, ok := models.ParseReactionKind(r.URL.Query().Get("kind")) 34 35 if !ok { 35 36 log.Println("invalid reaction kind") 36 37 return ··· 46 47 case http.MethodPost: 47 48 createdAt := time.Now().Format(time.RFC3339) 48 49 rkey := tid.TID() 49 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 50 51 Collection: tangled.FeedReactionNSID, 51 52 Repo: currentUser.Did, 52 53 Rkey: rkey, ··· 69 70 return 70 71 } 71 72 72 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 73 74 if err != nil { 74 - log.Println("failed to get reaction count for ", subjectUri) 75 + log.Println("failed to get reactions for ", subjectUri) 75 76 } 76 77 77 78 log.Println("created atproto record: ", resp.Uri) ··· 79 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 81 ThreadAt: subjectUri, 81 82 Kind: reactionKind, 82 - Count: count, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 83 85 IsReacted: true, 84 86 }) 85 87 ··· 91 93 return 92 94 } 93 95 94 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 95 97 Collection: tangled.FeedReactionNSID, 96 98 Repo: currentUser.Did, 97 99 Rkey: reaction.Rkey, ··· 108 110 // this is not an issue, the firehose event might have already done this 109 111 } 110 112 111 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 112 114 if err != nil { 113 - log.Println("failed to get reaction count for ", subjectUri) 115 + log.Println("failed to get reactions for ", subjectUri) 114 116 return 115 117 } 116 118 117 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 120 ThreadAt: subjectUri, 119 121 Kind: reactionKind, 120 - Count: count, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 121 124 IsReacted: false, 122 125 }) 123 126
+59 -26
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 - "tangled.sh/tangled.sh/core/appview/issues" 10 - "tangled.sh/tangled.sh/core/appview/knots" 11 - "tangled.sh/tangled.sh/core/appview/middleware" 12 - oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" 13 - "tangled.sh/tangled.sh/core/appview/pipelines" 14 - "tangled.sh/tangled.sh/core/appview/pulls" 15 - "tangled.sh/tangled.sh/core/appview/repo" 16 - "tangled.sh/tangled.sh/core/appview/settings" 17 - "tangled.sh/tangled.sh/core/appview/signup" 18 - "tangled.sh/tangled.sh/core/appview/spindles" 19 - "tangled.sh/tangled.sh/core/appview/state/userutil" 20 - avstrings "tangled.sh/tangled.sh/core/appview/strings" 21 - "tangled.sh/tangled.sh/core/log" 8 + "tangled.org/core/appview/issues" 9 + "tangled.org/core/appview/knots" 10 + "tangled.org/core/appview/labels" 11 + "tangled.org/core/appview/middleware" 12 + "tangled.org/core/appview/notifications" 13 + "tangled.org/core/appview/pipelines" 14 + "tangled.org/core/appview/pulls" 15 + "tangled.org/core/appview/repo" 16 + "tangled.org/core/appview/settings" 17 + "tangled.org/core/appview/signup" 18 + "tangled.org/core/appview/spindles" 19 + "tangled.org/core/appview/state/userutil" 20 + avstrings "tangled.org/core/appview/strings" 21 + "tangled.org/core/log" 22 22 ) 23 23 24 24 func (s *State) Router() http.Handler { ··· 34 34 35 35 router.Get("/favicon.svg", s.Favicon) 36 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 + router.Get("/robots.txt", s.RobotsTxt) 37 39 38 40 userRouter := s.UserRouter(&middleware) 39 41 standardRouter := s.StandardRouter(&middleware) ··· 90 92 r.Mount("/issues", s.IssuesRouter(mw)) 91 93 r.Mount("/pulls", s.PullsRouter(mw)) 92 94 r.Mount("/pipelines", s.PipelinesRouter(mw)) 95 + r.Mount("/labels", s.LabelsRouter(mw)) 93 96 94 97 // These routes get proxied to the knot 95 98 r.Get("/info/refs", s.InfoRefs) ··· 111 114 112 115 r.Handle("/static/*", s.pages.Static()) 113 116 114 - r.Get("/", s.Timeline) 117 + r.Get("/", s.HomeOrTimeline) 118 + r.Get("/timeline", s.Timeline) 119 + r.Get("/upgradeBanner", s.UpgradeBanner) 120 + 121 + // special-case handler for serving tangled.org/core 122 + r.Get("/core", s.Core()) 123 + 124 + r.Get("/login", s.Login) 125 + r.Post("/login", s.Login) 126 + r.Post("/logout", s.Logout) 115 127 116 128 r.Route("/repo", func(r chi.Router) { 117 129 r.Route("/new", func(r chi.Router) { ··· 122 134 // r.Post("/import", s.ImportRepo) 123 135 }) 124 136 137 + r.Get("/goodfirstissues", s.GoodFirstIssues) 138 + 125 139 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 126 140 r.Post("/", s.Follow) 127 141 r.Delete("/", s.Follow) ··· 149 163 r.Mount("/strings", s.StringsRouter(mw)) 150 164 r.Mount("/knots", s.KnotsRouter()) 151 165 r.Mount("/spindles", s.SpindlesRouter()) 166 + r.Mount("/notifications", s.NotificationsRouter(mw)) 167 + 152 168 r.Mount("/signup", s.SignupRouter()) 153 - r.Mount("/", s.OAuthRouter()) 169 + r.Mount("/", s.oauth.Router()) 154 170 155 171 r.Get("/keys/{user}", s.Keys) 156 172 r.Get("/terms", s.TermsOfService) 157 173 r.Get("/privacy", s.PrivacyPolicy) 174 + r.Get("/brand", s.Brand) 158 175 159 176 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 160 177 s.pages.Error404(w) ··· 162 179 return r 163 180 } 164 181 165 - func (s *State) OAuthRouter() http.Handler { 166 - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 167 - oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) 168 - return oauth.Router() 182 + // Core serves tangled.org/core go-import meta tags, and redirects 183 + // to the core repository if accessed normally. 184 + func (s *State) Core() http.HandlerFunc { 185 + return func(w http.ResponseWriter, r *http.Request) { 186 + if r.URL.Query().Get("go-get") == "1" { 187 + w.Header().Set("Content-Type", "text/html") 188 + w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`)) 189 + return 190 + } 191 + 192 + http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 193 + } 169 194 } 170 195 171 196 func (s *State) SettingsRouter() http.Handler { ··· 219 244 Db: s.db, 220 245 OAuth: s.oauth, 221 246 Pages: s.pages, 222 - Config: s.config, 223 - Enforcer: s.enforcer, 224 247 IdResolver: s.idResolver, 225 - Knotstream: s.knotstream, 248 + Notifier: s.notifier, 226 249 Logger: logger, 227 250 } 228 251 ··· 230 253 } 231 254 232 255 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 233 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 256 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 234 257 return issues.Router(mw) 235 258 } 236 259 237 260 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 238 - pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 261 + pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.enforcer) 239 262 return pulls.Router(mw) 240 263 } 241 264 242 265 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 243 266 logger := log.New("repo") 244 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 267 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator) 245 268 return repo.Router(mw) 246 269 } 247 270 248 271 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 249 272 pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 250 273 return pipes.Router(mw) 274 + } 275 + 276 + func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 277 + ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 278 + return ls.Router(mw) 279 + } 280 + 281 + func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 282 + notifs := notifications.New(s.db, s.oauth, s.pages) 283 + return notifs.Router(mw) 251 284 } 252 285 253 286 func (s *State) SignupRouter() http.Handler {
+11 -10
appview/state/spindlestream.go
··· 9 9 "time" 10 10 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview/cache" 14 - "tangled.sh/tangled.sh/core/appview/config" 15 - "tangled.sh/tangled.sh/core/appview/db" 16 - ec "tangled.sh/tangled.sh/core/eventconsumer" 17 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 18 - "tangled.sh/tangled.sh/core/log" 19 - "tangled.sh/tangled.sh/core/rbac" 20 - spindle "tangled.sh/tangled.sh/core/spindle/models" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/cache" 14 + "tangled.org/core/appview/config" 15 + "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/models" 17 + ec "tangled.org/core/eventconsumer" 18 + "tangled.org/core/eventconsumer/cursor" 19 + "tangled.org/core/log" 20 + "tangled.org/core/rbac" 21 + spindle "tangled.org/core/spindle/models" 21 22 ) 22 23 23 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 89 90 created = t 90 91 } 91 92 92 - status := db.PipelineStatus{ 93 + status := models.PipelineStatus{ 93 94 Spindle: source.Key(), 94 95 Rkey: msg.Rkey, 95 96 PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
+10 -9
appview/state/star.go
··· 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/appview/pages" 14 - "tangled.sh/tangled.sh/core/tid" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pages" 15 + "tangled.org/core/tid" 15 16 ) 16 17 17 18 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 39 40 case http.MethodPost: 40 41 createdAt := time.Now().Format(time.RFC3339) 41 42 rkey := tid.TID() 42 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 43 44 Collection: tangled.FeedStarNSID, 44 45 Repo: currentUser.Did, 45 46 Rkey: rkey, ··· 55 56 } 56 57 log.Println("created atproto record: ", resp.Uri) 57 58 58 - star := &db.Star{ 59 + star := &models.Star{ 59 60 StarredByDid: currentUser.Did, 60 61 RepoAt: subjectUri, 61 62 Rkey: rkey, ··· 77 78 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 78 79 IsStarred: true, 79 80 RepoAt: subjectUri, 80 - Stats: db.RepoStats{ 81 + Stats: models.RepoStats{ 81 82 StarCount: starCount, 82 83 }, 83 84 }) ··· 91 92 return 92 93 } 93 94 94 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 95 96 Collection: tangled.FeedStarNSID, 96 97 Repo: currentUser.Did, 97 98 Rkey: star.Rkey, ··· 119 120 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 120 121 IsStarred: false, 121 122 RepoAt: subjectUri, 122 - Stats: db.RepoStats{ 123 + Stats: models.RepoStats{ 123 124 StarCount: starCount, 124 125 }, 125 126 })
+221 -45
appview/state/state.go
··· 11 11 "strings" 12 12 "time" 13 13 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview" 16 + "tangled.org/core/appview/cache" 17 + "tangled.org/core/appview/cache/session" 18 + "tangled.org/core/appview/config" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/notify" 22 + dbnotify "tangled.org/core/appview/notify/db" 23 + phnotify "tangled.org/core/appview/notify/posthog" 24 + "tangled.org/core/appview/oauth" 25 + "tangled.org/core/appview/pages" 26 + "tangled.org/core/appview/reporesolver" 27 + "tangled.org/core/appview/validator" 28 + xrpcclient "tangled.org/core/appview/xrpcclient" 29 + "tangled.org/core/eventconsumer" 30 + "tangled.org/core/idresolver" 31 + "tangled.org/core/jetstream" 32 + tlog "tangled.org/core/log" 33 + "tangled.org/core/rbac" 34 + "tangled.org/core/tid" 35 + 14 36 comatproto "github.com/bluesky-social/indigo/api/atproto" 37 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 38 "github.com/bluesky-social/indigo/atproto/syntax" 16 39 lexutil "github.com/bluesky-social/indigo/lex/util" 17 40 securejoin "github.com/cyphar/filepath-securejoin" 18 41 "github.com/go-chi/chi/v5" 19 42 "github.com/posthog/posthog-go" 20 - "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview" 22 - "tangled.sh/tangled.sh/core/appview/cache" 23 - "tangled.sh/tangled.sh/core/appview/cache/session" 24 - "tangled.sh/tangled.sh/core/appview/config" 25 - "tangled.sh/tangled.sh/core/appview/db" 26 - "tangled.sh/tangled.sh/core/appview/notify" 27 - "tangled.sh/tangled.sh/core/appview/oauth" 28 - "tangled.sh/tangled.sh/core/appview/pages" 29 - posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 - "tangled.sh/tangled.sh/core/appview/reporesolver" 31 - xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 - "tangled.sh/tangled.sh/core/eventconsumer" 33 - "tangled.sh/tangled.sh/core/idresolver" 34 - "tangled.sh/tangled.sh/core/jetstream" 35 - tlog "tangled.sh/tangled.sh/core/log" 36 - "tangled.sh/tangled.sh/core/rbac" 37 - "tangled.sh/tangled.sh/core/tid" 38 - // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 39 43 ) 40 44 41 45 type State struct { ··· 53 57 knotstream *eventconsumer.Consumer 54 58 spindlestream *eventconsumer.Consumer 55 59 logger *slog.Logger 60 + validator *validator.Validator 56 61 } 57 62 58 63 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 72 77 res = idresolver.DefaultResolver() 73 78 } 74 79 75 - pgs := pages.NewPages(config, res) 76 - 80 + pages := pages.NewPages(config, res) 77 81 cache := cache.New(config.Redis.Addr) 78 82 sess := session.New(cache) 79 - 80 - oauth := oauth.NewOAuth(config, sess) 83 + oauth2, err := oauth.New(config) 84 + if err != nil { 85 + return nil, fmt.Errorf("failed to start oauth handler: %w", err) 86 + } 87 + validator := validator.New(d, res, enforcer) 81 88 82 89 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 83 90 if err != nil { ··· 86 93 87 94 repoResolver := reporesolver.New(config, enforcer, res, d) 88 95 89 - wrapper := db.DbWrapper{d} 96 + wrapper := db.DbWrapper{Execer: d} 90 97 jc, err := jetstream.NewJetstreamClient( 91 98 config.Jetstream.Endpoint, 92 99 "appview", ··· 101 108 tangled.StringNSID, 102 109 tangled.RepoIssueNSID, 103 110 tangled.RepoIssueCommentNSID, 111 + tangled.LabelDefinitionNSID, 112 + tangled.LabelOpNSID, 104 113 }, 105 114 nil, 106 115 slog.Default(), ··· 115 124 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 116 125 } 117 126 127 + if err := BackfillDefaultDefs(d, res); err != nil { 128 + return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 129 + } 130 + 118 131 ingester := appview.Ingester{ 119 132 Db: wrapper, 120 133 Enforcer: enforcer, 121 134 IdResolver: res, 122 135 Config: config, 123 136 Logger: tlog.New("ingester"), 137 + Validator: validator, 124 138 } 125 139 err = jc.StartJetstream(ctx, ingester.Ingest()) 126 140 if err != nil { ··· 140 154 spindlestream.Start(ctx) 141 155 142 156 var notifiers []notify.Notifier 157 + 158 + // Always add the database notifier 159 + notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 160 + 161 + // Add other notifiers in production only 143 162 if !config.Core.Dev { 144 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 163 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 145 164 } 146 165 notifier := notify.NewMergedNotifier(notifiers...) 147 166 148 167 state := &State{ 149 168 d, 150 169 notifier, 151 - oauth, 170 + oauth2, 152 171 enforcer, 153 - pgs, 172 + pages, 154 173 sess, 155 174 res, 156 175 posthog, ··· 160 179 knotstream, 161 180 spindlestream, 162 181 slog.Default(), 182 + validator, 163 183 } 164 184 165 185 return state, nil 166 186 } 167 187 188 + func (s *State) Close() error { 189 + // other close up logic goes here 190 + return s.db.Close() 191 + } 192 + 168 193 func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 169 194 w.Header().Set("Content-Type", "image/svg+xml") 170 195 w.Header().Set("Cache-Control", "public, max-age=31536000") // one year ··· 178 203 s.pages.Favicon(w) 179 204 } 180 205 206 + func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 207 + w.Header().Set("Content-Type", "text/plain") 208 + w.Header().Set("Cache-Control", "public, max-age=86400") // one day 209 + 210 + robotsTxt := `User-agent: * 211 + Allow: / 212 + ` 213 + w.Write([]byte(robotsTxt)) 214 + } 215 + 216 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 217 + const manifestJson = `{ 218 + "name": "tangled", 219 + "description": "tightly-knit social coding.", 220 + "icons": [ 221 + { 222 + "src": "/favicon.svg", 223 + "sizes": "144x144" 224 + } 225 + ], 226 + "start_url": "/", 227 + "id": "org.tangled", 228 + 229 + "display": "standalone", 230 + "background_color": "#111827", 231 + "theme_color": "#111827" 232 + }` 233 + 234 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 235 + w.Header().Set("Content-Type", "application/json") 236 + w.Write([]byte(manifestJson)) 237 + } 238 + 181 239 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 182 240 user := s.oauth.GetUser(r) 183 241 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 192 250 }) 193 251 } 194 252 253 + func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 254 + user := s.oauth.GetUser(r) 255 + s.pages.Brand(w, pages.BrandParams{ 256 + LoggedInUser: user, 257 + }) 258 + } 259 + 260 + func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 261 + if s.oauth.GetUser(r) != nil { 262 + s.Timeline(w, r) 263 + return 264 + } 265 + s.Home(w, r) 266 + } 267 + 195 268 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 196 269 user := s.oauth.GetUser(r) 197 270 198 - timeline, err := db.MakeTimeline(s.db) 271 + // TODO: set this flag based on the UI 272 + filtered := false 273 + 274 + var userDid string 275 + if user != nil { 276 + userDid = user.Did 277 + } 278 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 199 279 if err != nil { 200 280 log.Println(err) 201 281 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 208 288 return 209 289 } 210 290 291 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 292 + if err != nil { 293 + // non-fatal 294 + } 295 + 211 296 s.pages.Timeline(w, pages.TimelineParams{ 212 297 LoggedInUser: user, 213 298 Timeline: timeline, 214 299 Repos: repos, 300 + GfiLabel: gfiLabel, 301 + }) 302 + } 303 + 304 + func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 305 + user := s.oauth.GetUser(r) 306 + if user == nil { 307 + return 308 + } 309 + 310 + l := s.logger.With("handler", "UpgradeBanner") 311 + l = l.With("did", user.Did) 312 + 313 + regs, err := db.GetRegistrations( 314 + s.db, 315 + db.FilterEq("did", user.Did), 316 + db.FilterEq("needs_upgrade", 1), 317 + ) 318 + if err != nil { 319 + l.Error("non-fatal: failed to get registrations", "err", err) 320 + } 321 + 322 + spindles, err := db.GetSpindles( 323 + s.db, 324 + db.FilterEq("owner", user.Did), 325 + db.FilterEq("needs_upgrade", 1), 326 + ) 327 + if err != nil { 328 + l.Error("non-fatal: failed to get spindles", "err", err) 329 + } 330 + 331 + if regs == nil && spindles == nil { 332 + return 333 + } 334 + 335 + s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{ 336 + Registrations: regs, 337 + Spindles: spindles, 338 + }) 339 + } 340 + 341 + func (s *State) Home(w http.ResponseWriter, r *http.Request) { 342 + // TODO: set this flag based on the UI 343 + filtered := false 344 + 345 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 346 + if err != nil { 347 + log.Println(err) 348 + s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 349 + return 350 + } 351 + 352 + repos, err := db.GetTopStarredReposLastWeek(s.db) 353 + if err != nil { 354 + log.Println(err) 355 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 356 + return 357 + } 358 + 359 + s.pages.Home(w, pages.TimelineParams{ 360 + LoggedInUser: nil, 361 + Timeline: timeline, 362 + Repos: repos, 215 363 }) 216 364 } 217 365 ··· 243 391 244 392 for _, k := range pubKeys { 245 393 key := strings.TrimRight(k.Key, "\n") 246 - w.Write([]byte(fmt.Sprintln(key))) 394 + fmt.Fprintln(w, key) 247 395 } 248 396 } 249 397 ··· 303 451 304 452 user := s.oauth.GetUser(r) 305 453 l = l.With("did", user.Did) 306 - l = l.With("handle", user.Handle) 307 454 308 455 // form validation 309 456 domain := r.FormValue("domain") ··· 343 490 } 344 491 345 492 // Check for existing repos 346 - existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 493 + existingRepo, err := db.GetRepo( 494 + s.db, 495 + db.FilterEq("did", user.Did), 496 + db.FilterEq("name", repoName), 497 + ) 347 498 if err == nil && existingRepo != nil { 348 499 l.Info("repo exists") 349 500 s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) ··· 352 503 353 504 // create atproto record for this repo 354 505 rkey := tid.TID() 355 - repo := &db.Repo{ 506 + repo := &models.Repo{ 356 507 Did: user.Did, 357 508 Name: repoName, 358 509 Knot: domain, 359 510 Rkey: rkey, 360 511 Description: description, 512 + Created: time.Now(), 513 + Labels: models.DefaultLabelDefs(), 361 514 } 515 + record := repo.AsRecord() 362 516 363 - xrpcClient, err := s.oauth.AuthorizedClient(r) 517 + atpClient, err := s.oauth.AuthorizedClient(r) 364 518 if err != nil { 365 519 l.Info("PDS write failed", "err", err) 366 520 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 367 521 return 368 522 } 369 523 370 - createdAt := time.Now().Format(time.RFC3339) 371 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 524 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 372 525 Collection: tangled.RepoNSID, 373 526 Repo: user.Did, 374 527 Rkey: rkey, 375 528 Record: &lexutil.LexiconTypeDecoder{ 376 - Val: &tangled.Repo{ 377 - Knot: repo.Knot, 378 - Name: repoName, 379 - CreatedAt: createdAt, 380 - Owner: user.Did, 381 - }}, 529 + Val: &record, 530 + }, 382 531 }) 383 532 if err != nil { 384 533 l.Info("PDS write failed", "err", err) ··· 404 553 rollback := func() { 405 554 err1 := tx.Rollback() 406 555 err2 := s.enforcer.E.LoadPolicy() 407 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 556 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 408 557 409 558 // ignore txn complete errors, this is okay 410 559 if errors.Is(err1, sql.ErrTxDone) { ··· 477 626 aturi = "" 478 627 479 628 s.notifier.NewRepo(r.Context(), repo) 480 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 629 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 481 630 } 482 631 } 483 632 484 633 // this is used to rollback changes made to the PDS 485 634 // 486 635 // it is a no-op if the provided ATURI is empty 487 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 636 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 488 637 if aturi == "" { 489 638 return nil 490 639 } ··· 495 644 repo := parsed.Authority().String() 496 645 rkey := parsed.RecordKey().String() 497 646 498 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 647 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 499 648 Collection: collection, 500 649 Repo: repo, 501 650 Rkey: rkey, 502 651 }) 503 652 return err 504 653 } 654 + 655 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 656 + defaults := models.DefaultLabelDefs() 657 + defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 658 + if err != nil { 659 + return err 660 + } 661 + // already present 662 + if len(defaultLabels) == len(defaults) { 663 + return nil 664 + } 665 + 666 + labelDefs, err := models.FetchDefaultDefs(r) 667 + if err != nil { 668 + return err 669 + } 670 + 671 + // Insert each label definition to the database 672 + for _, labelDef := range labelDefs { 673 + _, err = db.AddLabelDefinition(e, &labelDef) 674 + if err != nil { 675 + return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) 676 + } 677 + } 678 + 679 + return nil 680 + }
+29 -82
appview/strings/strings.go
··· 5 5 "log/slog" 6 6 "net/http" 7 7 "path" 8 - "slices" 9 8 "strconv" 10 9 "time" 11 10 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview/config" 14 - "tangled.sh/tangled.sh/core/appview/db" 15 - "tangled.sh/tangled.sh/core/appview/middleware" 16 - "tangled.sh/tangled.sh/core/appview/oauth" 17 - "tangled.sh/tangled.sh/core/appview/pages" 18 - "tangled.sh/tangled.sh/core/appview/pages/markup" 19 - "tangled.sh/tangled.sh/core/eventconsumer" 20 - "tangled.sh/tangled.sh/core/idresolver" 21 - "tangled.sh/tangled.sh/core/rbac" 22 - "tangled.sh/tangled.sh/core/tid" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/middleware" 14 + "tangled.org/core/appview/models" 15 + "tangled.org/core/appview/notify" 16 + "tangled.org/core/appview/oauth" 17 + "tangled.org/core/appview/pages" 18 + "tangled.org/core/appview/pages/markup" 19 + "tangled.org/core/idresolver" 20 + "tangled.org/core/tid" 23 21 24 22 "github.com/bluesky-social/indigo/api/atproto" 25 23 "github.com/bluesky-social/indigo/atproto/identity" 26 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 + "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 27 28 lexutil "github.com/bluesky-social/indigo/lex/util" 28 - "github.com/go-chi/chi/v5" 29 29 ) 30 30 31 31 type Strings struct { 32 32 Db *db.DB 33 33 OAuth *oauth.OAuth 34 34 Pages *pages.Pages 35 - Config *config.Config 36 - Enforcer *rbac.Enforcer 37 35 IdResolver *idresolver.Resolver 38 36 Logger *slog.Logger 39 - Knotstream *eventconsumer.Consumer 37 + Notifier notify.Notifier 40 38 } 41 39 42 40 func (s *Strings) Router(mw *middleware.Middleware) http.Handler { ··· 161 159 } 162 160 163 161 func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 164 - l := s.Logger.With("handler", "dashboard") 165 - 166 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 167 - if !ok { 168 - l.Error("malformed middleware") 169 - w.WriteHeader(http.StatusInternalServerError) 170 - return 171 - } 172 - l = l.With("did", id.DID, "handle", id.Handle) 173 - 174 - all, err := db.GetStrings( 175 - s.Db, 176 - 0, 177 - db.FilterEq("did", id.DID), 178 - ) 179 - if err != nil { 180 - l.Error("failed to fetch strings", "err", err) 181 - w.WriteHeader(http.StatusInternalServerError) 182 - return 183 - } 184 - 185 - slices.SortFunc(all, func(a, b db.String) int { 186 - if a.Created.After(b.Created) { 187 - return -1 188 - } else { 189 - return 1 190 - } 191 - }) 192 - 193 - profile, err := db.GetProfile(s.Db, id.DID.String()) 194 - if err != nil { 195 - l.Error("failed to fetch user profile", "err", err) 196 - w.WriteHeader(http.StatusInternalServerError) 197 - return 198 - } 199 - loggedInUser := s.OAuth.GetUser(r) 200 - followStatus := db.IsNotFollowing 201 - if loggedInUser != nil { 202 - followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 - } 204 - 205 - followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 - if err != nil { 207 - l.Error("failed to get follow stats", "err", err) 208 - } 209 - 210 - s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 - LoggedInUser: s.OAuth.GetUser(r), 212 - Card: pages.ProfileCard{ 213 - UserDid: id.DID.String(), 214 - UserHandle: id.Handle.String(), 215 - Profile: profile, 216 - FollowStatus: followStatus, 217 - FollowersCount: followStats.Followers, 218 - FollowingCount: followStats.Following, 219 - }, 220 - Strings: all, 221 - }) 162 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 222 163 } 223 164 224 165 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { ··· 297 238 description := r.FormValue("description") 298 239 299 240 // construct new string from form values 300 - entry := db.String{ 241 + entry := models.String{ 301 242 Did: first.Did, 302 243 Rkey: first.Rkey, 303 244 Filename: filename, ··· 315 256 } 316 257 317 258 // first replace the existing record in the PDS 318 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 319 260 if err != nil { 320 261 fail("Failed to updated existing record.", err) 321 262 return 322 263 } 323 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 324 265 Collection: tangled.StringNSID, 325 266 Repo: entry.Did.String(), 326 267 Rkey: entry.Rkey, ··· 342 283 return 343 284 } 344 285 286 + s.Notifier.EditString(r.Context(), &entry) 287 + 345 288 // if that went okay, redir to the string 346 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 347 290 } 348 291 349 292 } ··· 378 321 379 322 description := r.FormValue("description") 380 323 381 - string := db.String{ 324 + string := models.String{ 382 325 Did: syntax.DID(user.Did), 383 326 Rkey: tid.TID(), 384 327 Filename: filename, ··· 395 338 return 396 339 } 397 340 398 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 399 342 Collection: tangled.StringNSID, 400 343 Repo: user.Did, 401 344 Rkey: string.Rkey, ··· 415 358 fail("Failed to create string.", err) 416 359 return 417 360 } 361 + 362 + s.Notifier.NewString(r.Context(), &string) 418 363 419 364 // successful 420 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 421 366 } 422 367 } 423 368 ··· 458 403 return 459 404 } 460 405 461 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 406 + s.Notifier.DeleteString(r.Context(), user.Did, rkey) 407 + 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 462 409 } 463 410 464 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+54
appview/validator/issue.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + ) 10 + 11 + func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 12 + // if comments have parents, only ingest ones that are 1 level deep 13 + if comment.ReplyTo != nil { 14 + parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) 15 + if err != nil { 16 + return fmt.Errorf("failed to fetch parent comment: %w", err) 17 + } 18 + if len(parents) != 1 { 19 + return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 20 + } 21 + 22 + // depth check 23 + parent := parents[0] 24 + if parent.ReplyTo != nil { 25 + return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 26 + } 27 + } 28 + 29 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 30 + return fmt.Errorf("body is empty after HTML sanitization") 31 + } 32 + 33 + return nil 34 + } 35 + 36 + func (v *Validator) ValidateIssue(issue *models.Issue) error { 37 + if issue.Title == "" { 38 + return fmt.Errorf("issue title is empty") 39 + } 40 + 41 + if issue.Body == "" { 42 + return fmt.Errorf("issue body is empty") 43 + } 44 + 45 + if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { 46 + return fmt.Errorf("title is empty after HTML sanitization") 47 + } 48 + 49 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { 50 + return fmt.Errorf("body is empty after HTML sanitization") 51 + } 52 + 53 + return nil 54 + }
+217
appview/validator/label.go
··· 1 + package validator 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "regexp" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "golang.org/x/exp/slices" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/models" 13 + ) 14 + 15 + var ( 16 + // Label name should be alphanumeric with hyphens/underscores, but not start/end with them 17 + labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) 18 + // Color should be a valid hex color 19 + colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) 20 + // You can only label issues and pulls presently 21 + validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 22 + ) 23 + 24 + func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { 25 + if label.Name == "" { 26 + return fmt.Errorf("label name is empty") 27 + } 28 + if len(label.Name) > 40 { 29 + return fmt.Errorf("label name too long (max 40 graphemes)") 30 + } 31 + if len(label.Name) < 1 { 32 + return fmt.Errorf("label name too short (min 1 grapheme)") 33 + } 34 + if !labelNameRegex.MatchString(label.Name) { 35 + return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") 36 + } 37 + 38 + if !label.ValueType.IsConcreteType() { 39 + return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type) 40 + } 41 + 42 + // null type checks: cannot be enums, multiple or explicit format 43 + if label.ValueType.IsNull() && label.ValueType.IsEnum() { 44 + return fmt.Errorf("null type cannot be used in conjunction with enum type") 45 + } 46 + if label.ValueType.IsNull() && label.Multiple { 47 + return fmt.Errorf("null type labels cannot be multiple") 48 + } 49 + if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() { 50 + return fmt.Errorf("format cannot be used in conjunction with null type") 51 + } 52 + 53 + // format checks: cannot be used with enum, or integers 54 + if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() { 55 + return fmt.Errorf("enum types cannot be used in conjunction with format specification") 56 + } 57 + 58 + if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() { 59 + return fmt.Errorf("format specifications are only permitted on string types") 60 + } 61 + 62 + // validate scope (nsid format) 63 + if label.Scope == nil { 64 + return fmt.Errorf("scope is required") 65 + } 66 + for _, s := range label.Scope { 67 + if _, err := syntax.ParseNSID(s); err != nil { 68 + return fmt.Errorf("failed to parse scope: %w", err) 69 + } 70 + if !slices.Contains(validScopes, s) { 71 + return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) 72 + } 73 + } 74 + 75 + // validate color if provided 76 + if label.Color != nil { 77 + color := strings.TrimSpace(*label.Color) 78 + if color == "" { 79 + // empty color is fine, set to nil 80 + label.Color = nil 81 + } else { 82 + if !colorRegex.MatchString(color) { 83 + return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") 84 + } 85 + // expand 3-digit hex to 6-digit hex 86 + if len(color) == 4 { // #ABC 87 + color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) 88 + } 89 + // convert to uppercase for consistency 90 + color = strings.ToUpper(color) 91 + label.Color = &color 92 + } 93 + } 94 + 95 + return nil 96 + } 97 + 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 + if labelDef == nil { 100 + return fmt.Errorf("label definition is required") 101 + } 102 + if repo == nil { 103 + return fmt.Errorf("repo is required") 104 + } 105 + if labelOp == nil { 106 + return fmt.Errorf("label operation is required") 107 + } 108 + 109 + // validate permissions: only collaborators can apply labels currently 110 + // 111 + // TODO: introduce a repo:triage permission 112 + ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) 113 + if err != nil { 114 + return fmt.Errorf("failed to enforce permissions: %w", err) 115 + } 116 + if !ok { 117 + return fmt.Errorf("unauhtorized label operation") 118 + } 119 + 120 + expectedKey := labelDef.AtUri().String() 121 + if labelOp.OperandKey != expectedKey { 122 + return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 123 + } 124 + 125 + if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { 126 + return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 127 + } 128 + 129 + if labelOp.Subject == "" { 130 + return fmt.Errorf("subject URI is required") 131 + } 132 + if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil { 133 + return fmt.Errorf("invalid subject URI: %w", err) 134 + } 135 + 136 + if err := v.validateOperandValue(labelDef, labelOp); err != nil { 137 + return fmt.Errorf("invalid operand value: %w", err) 138 + } 139 + 140 + // Validate performed time is not zero/invalid 141 + if labelOp.PerformedAt.IsZero() { 142 + return fmt.Errorf("performed_at timestamp is required") 143 + } 144 + 145 + return nil 146 + } 147 + 148 + func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 149 + valueType := labelDef.ValueType 150 + 151 + // this is permitted, it "unsets" a label 152 + if labelOp.OperandValue == "" { 153 + labelOp.Operation = models.LabelOperationDel 154 + return nil 155 + } 156 + 157 + switch valueType.Type { 158 + case models.ConcreteTypeNull: 159 + // For null type, value should be empty 160 + if labelOp.OperandValue != "null" { 161 + return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) 162 + } 163 + 164 + case models.ConcreteTypeString: 165 + // For string type, validate enum constraints if present 166 + if valueType.IsEnum() { 167 + if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 168 + return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 169 + } 170 + } 171 + 172 + switch valueType.Format { 173 + case models.ValueTypeFormatDid: 174 + id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) 175 + if err != nil { 176 + return fmt.Errorf("failed to resolve did/handle: %w", err) 177 + } 178 + 179 + labelOp.OperandValue = id.DID.String() 180 + 181 + case models.ValueTypeFormatAny, "": 182 + default: 183 + return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 184 + } 185 + 186 + case models.ConcreteTypeInt: 187 + if labelOp.OperandValue == "" { 188 + return fmt.Errorf("integer type requires non-empty value") 189 + } 190 + if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil { 191 + return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) 192 + } 193 + 194 + if valueType.IsEnum() { 195 + if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 196 + return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 197 + } 198 + } 199 + 200 + case models.ConcreteTypeBool: 201 + if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { 202 + return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) 203 + } 204 + 205 + // validate enum constraints if present (though uncommon for booleans) 206 + if valueType.IsEnum() { 207 + if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 208 + return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 209 + } 210 + } 211 + 212 + default: 213 + return fmt.Errorf("unsupported value type: %q", valueType.Type) 214 + } 215 + 216 + return nil 217 + }
+27
appview/validator/string.go
··· 1 + package validator 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "unicode/utf8" 7 + 8 + "tangled.org/core/appview/models" 9 + ) 10 + 11 + func (v *Validator) ValidateString(s *models.String) error { 12 + var err error 13 + 14 + if utf8.RuneCountInString(s.Filename) > 140 { 15 + err = errors.Join(err, fmt.Errorf("filename too long")) 16 + } 17 + 18 + if utf8.RuneCountInString(s.Description) > 280 { 19 + err = errors.Join(err, fmt.Errorf("description too long")) 20 + } 21 + 22 + if len(s.Contents) == 0 { 23 + err = errors.Join(err, fmt.Errorf("contents is empty")) 24 + } 25 + 26 + return err 27 + }
+24
appview/validator/validator.go
··· 1 + package validator 2 + 3 + import ( 4 + "tangled.org/core/appview/db" 5 + "tangled.org/core/appview/pages/markup" 6 + "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 8 + ) 9 + 10 + type Validator struct { 11 + db *db.DB 12 + sanitizer markup.Sanitizer 13 + resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 15 + } 16 + 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 18 + return &Validator{ 19 + db: db, 20 + sanitizer: markup.NewSanitizer(), 21 + resolver: res, 22 + enforcer: enforcer, 23 + } 24 + }
+10 -103
appview/xrpcclient/xrpc.go
··· 1 1 package xrpcclient 2 2 3 3 import ( 4 - "bytes" 5 - "context" 6 4 "errors" 7 - "fmt" 8 - "io" 9 5 "net/http" 10 6 11 - "github.com/bluesky-social/indigo/api/atproto" 12 - "github.com/bluesky-social/indigo/xrpc" 13 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 14 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 15 8 ) 16 9 17 - type Client struct { 18 - *oauth.XrpcClient 19 - authArgs *oauth.XrpcAuthedRequestArgs 20 - } 21 - 22 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 23 - return &Client{ 24 - XrpcClient: client, 25 - authArgs: authArgs, 26 - } 27 - } 28 - 29 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 30 - var out atproto.RepoPutRecord_Output 31 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 32 - return nil, err 33 - } 34 - 35 - return &out, nil 36 - } 37 - 38 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 39 - var out atproto.RepoApplyWrites_Output 40 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 41 - return nil, err 42 - } 43 - 44 - return &out, nil 45 - } 46 - 47 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 48 - var out atproto.RepoGetRecord_Output 49 - 50 - params := map[string]interface{}{ 51 - "cid": cid, 52 - "collection": collection, 53 - "repo": repo, 54 - "rkey": rkey, 55 - } 56 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 57 - return nil, err 58 - } 59 - 60 - return &out, nil 61 - } 62 - 63 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 64 - var out atproto.RepoUploadBlob_Output 65 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 66 - return nil, err 67 - } 68 - 69 - return &out, nil 70 - } 71 - 72 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 73 - buf := new(bytes.Buffer) 74 - 75 - params := map[string]interface{}{ 76 - "cid": cid, 77 - "did": did, 78 - } 79 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 80 - return nil, err 81 - } 82 - 83 - return buf.Bytes(), nil 84 - } 85 - 86 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 87 - var out atproto.RepoDeleteRecord_Output 88 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 89 - return nil, err 90 - } 91 - 92 - return &out, nil 93 - } 94 - 95 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 96 - var out atproto.ServerGetServiceAuth_Output 97 - 98 - params := map[string]interface{}{ 99 - "aud": aud, 100 - "exp": exp, 101 - "lxm": lxm, 102 - } 103 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 104 - return nil, err 105 - } 106 - 107 - return &out, nil 108 - } 10 + var ( 11 + ErrXrpcUnsupported = errors.New("xrpc not supported on this knot") 12 + ErrXrpcUnauthorized = errors.New("unauthorized xrpc request") 13 + ErrXrpcFailed = errors.New("xrpc request failed") 14 + ErrXrpcInvalid = errors.New("invalid xrpc request") 15 + ) 109 16 110 17 // produces a more manageable error 111 18 func HandleXrpcErr(err error) error { ··· 115 22 116 23 var xrpcerr *indigoxrpc.Error 117 24 if ok := errors.As(err, &xrpcerr); !ok { 118 - return fmt.Errorf("Recieved invalid XRPC error response.") 25 + return ErrXrpcInvalid 119 26 } 120 27 121 28 switch xrpcerr.StatusCode { 122 29 case http.StatusNotFound: 123 - return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 30 + return ErrXrpcUnsupported 124 31 case http.StatusUnauthorized: 125 - return fmt.Errorf("Unauthorized XRPC request.") 32 + return ErrXrpcUnauthorized 126 33 default: 127 - return fmt.Errorf("Failed to perform operation. Try again later.") 34 + return ErrXrpcFailed 128 35 } 129 36 }
+5 -2
cmd/appview/main.go
··· 7 7 "net/http" 8 8 "os" 9 9 10 - "tangled.sh/tangled.sh/core/appview/config" 11 - "tangled.sh/tangled.sh/core/appview/state" 10 + "tangled.org/core/appview/config" 11 + "tangled.org/core/appview/state" 12 12 ) 13 13 14 14 func main() { ··· 23 23 } 24 24 25 25 state, err := state.Make(ctx, c) 26 + defer func() { 27 + log.Println(state.Close()) 28 + }() 26 29 27 30 if err != nil { 28 31 log.Fatal(err)
+1 -1
cmd/combinediff/main.go
··· 5 5 "os" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 8 + "tangled.org/core/patchutil" 9 9 ) 10 10 11 11 func main() {
+6 -2
cmd/gen.go
··· 2 2 3 3 import ( 4 4 cbg "github.com/whyrusleeping/cbor-gen" 5 - "tangled.sh/tangled.sh/core/api/tangled" 5 + "tangled.org/core/api/tangled" 6 6 ) 7 7 8 8 func main() { ··· 20 20 tangled.GitRefUpdate{}, 21 21 tangled.GitRefUpdate_CommitCountBreakdown{}, 22 22 tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 - tangled.GitRefUpdate_LangBreakdown{}, 24 23 tangled.GitRefUpdate_IndividualLanguageSize{}, 24 + tangled.GitRefUpdate_LangBreakdown{}, 25 25 tangled.GitRefUpdate_Meta{}, 26 26 tangled.GraphFollow{}, 27 27 tangled.Knot{}, 28 28 tangled.KnotMember{}, 29 + tangled.LabelDefinition{}, 30 + tangled.LabelDefinition_ValueType{}, 31 + tangled.LabelOp{}, 32 + tangled.LabelOp_Operand{}, 29 33 tangled.Pipeline{}, 30 34 tangled.Pipeline_CloneOpts{}, 31 35 tangled.Pipeline_ManualTriggerData{},
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 2 3 3 package main 4 4
+1 -1
cmd/interdiff/main.go
··· 5 5 "os" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 8 + "tangled.org/core/patchutil" 9 9 ) 10 10 11 11 func main() {
+5 -5
cmd/knot/main.go
··· 5 5 "os" 6 6 7 7 "github.com/urfave/cli/v3" 8 - "tangled.sh/tangled.sh/core/guard" 9 - "tangled.sh/tangled.sh/core/hook" 10 - "tangled.sh/tangled.sh/core/keyfetch" 11 - "tangled.sh/tangled.sh/core/knotserver" 12 - "tangled.sh/tangled.sh/core/log" 8 + "tangled.org/core/guard" 9 + "tangled.org/core/hook" 10 + "tangled.org/core/keyfetch" 11 + "tangled.org/core/knotserver" 12 + "tangled.org/core/log" 13 13 ) 14 14 15 15 func main() {
+3 -3
cmd/spindle/main.go
··· 4 4 "context" 5 5 "os" 6 6 7 - "tangled.sh/tangled.sh/core/log" 8 - "tangled.sh/tangled.sh/core/spindle" 9 - _ "tangled.sh/tangled.sh/core/tid" 7 + "tangled.org/core/log" 8 + "tangled.org/core/spindle" 9 + _ "tangled.org/core/tid" 10 10 ) 11 11 12 12 func main() {
+1 -1
cmd/verifysig/main.go
··· 7 7 "os" 8 8 "strings" 9 9 10 - "tangled.sh/tangled.sh/core/crypto" 10 + "tangled.org/core/crypto" 11 11 ) 12 12 13 13 func parseCommitObject(commitData string) (string, string, error) {
+9
consts/consts.go
··· 1 + package consts 2 + 3 + const ( 4 + TangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 5 + IcyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 6 + 7 + DefaultSpindle = "spindle.tangled.sh" 8 + DefaultKnot = "knot1.tangled.sh" 9 + )
+44
contrib/Tiltfile
··· 1 + common_env = { 2 + "TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""), 3 + "TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""), 4 + "TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"), 5 + "TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"), 6 + } 7 + 8 + nix_globs = ["nix/**", "flake.nix", "flake.lock"] 9 + 10 + local_resource( 11 + name="appview", 12 + serve_cmd="nix run .#watch-appview", 13 + serve_dir="..", 14 + deps=nix_globs, 15 + env=common_env, 16 + allow_parallel=True, 17 + ) 18 + 19 + local_resource( 20 + name="tailwind", 21 + serve_cmd="nix run .#watch-tailwind", 22 + serve_dir="..", 23 + deps=nix_globs, 24 + env=common_env, 25 + allow_parallel=True, 26 + ) 27 + 28 + local_resource( 29 + name="redis", 30 + serve_cmd="redis-server", 31 + serve_dir="..", 32 + deps=nix_globs, 33 + env=common_env, 34 + allow_parallel=True, 35 + ) 36 + 37 + local_resource( 38 + name="vm", 39 + serve_cmd="nix run --impure .#vm", 40 + serve_dir="..", 41 + deps=nix_globs, 42 + env=common_env, 43 + allow_parallel=True, 44 + )
+1 -1
crypto/verify.go
··· 9 9 10 10 "github.com/hiddeco/sshsig" 11 11 "golang.org/x/crypto/ssh" 12 - "tangled.sh/tangled.sh/core/types" 12 + "tangled.org/core/types" 13 13 ) 14 14 15 15 func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
+16
default.nix
··· 1 + # Default setup from https://git.lix.systems/lix-project/flake-compat 2 + let 3 + lockFile = builtins.fromJSON (builtins.readFile ./flake.lock); 4 + flake-compat-node = lockFile.nodes.${lockFile.nodes.root.inputs.flake-compat}; 5 + flake-compat = builtins.fetchTarball { 6 + inherit (flake-compat-node.locked) url; 7 + sha256 = flake-compat-node.locked.narHash; 8 + }; 9 + 10 + flake = ( 11 + import flake-compat { 12 + src = ./.; 13 + } 14 + ); 15 + in 16 + flake.defaultNix
+53 -12
docs/hacking.md
··· 48 48 redis-server 49 49 ``` 50 50 51 - ## running a knot 51 + ## running knots and spindles 52 52 53 53 An end-to-end knot setup requires setting up a machine with 54 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 55 quite cumbersome. So the nix flake provides a 56 56 `nixosConfiguration` to do so. 57 57 58 - To begin, grab your DID from http://localhost:3000/settings. 59 - Then, set `TANGLED_VM_KNOT_OWNER` and 60 - `TANGLED_VM_SPINDLE_OWNER` to your DID. 58 + <details> 59 + <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 60 + 61 + In order to build Tangled's dev VM on macOS, you will 62 + first need to set up a Linux Nix builder. The recommended 63 + way to do so is to run a [`darwin.linux-builder` 64 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 65 + and to register it in `nix.conf` as a builder for Linux 66 + with the same architecture as your Mac (`linux-aarch64` if 67 + you are using Apple Silicon). 68 + 69 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 70 + > the tangled repo so that it doesn't conflict with the other VM. For example, 71 + > you can do 72 + > 73 + > ```shell 74 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 75 + > ``` 76 + > 77 + > to store the builder VM in a temporary dir. 78 + > 79 + > You should read and follow [all the other intructions][darwin builder vm] to 80 + > avoid subtle problems. 81 + 82 + Alternatively, you can use any other method to set up a 83 + Linux machine with `nix` installed that you can `sudo ssh` 84 + into (in other words, root user on your Mac has to be able 85 + to ssh into the Linux machine without entering a password) 86 + and that has the same architecture as your Mac. See 87 + [remote builder 88 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 89 + for how to register such a builder in `nix.conf`. 61 90 62 - If you don't want to [set up a spindle](#running-a-spindle), 63 - you can use any placeholder value. 91 + > WARNING: If you'd like to use 92 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 93 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 94 + > ssh` works can be tricky. It seems to be [possible with 95 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 64 96 65 - You can now start a lightweight NixOS VM like so: 97 + </details> 98 + 99 + To begin, grab your DID from http://localhost:3000/settings. 100 + Then, set `TANGLED_VM_KNOT_OWNER` and 101 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 102 + lightweight NixOS VM like so: 66 103 67 104 ```bash 68 105 nix run --impure .#vm ··· 74 111 with `ssh` exposed on port 2222. 75 112 76 113 Once the services are running, head to 77 - http://localhost:3000/knots and hit verify (and similarly, 78 - http://localhost:3000/spindles to verify your spindle). It 79 - should verify the ownership of the services instantly if 80 - everything went smoothly. 114 + http://localhost:3000/knots and hit verify. It should 115 + verify the ownership of the services instantly if everything 116 + went smoothly. 81 117 82 118 You can push repositories to this VM with this ssh config 83 119 block on your main machine: ··· 97 133 git push local-dev main 98 134 ``` 99 135 100 - ## running a spindle 136 + ### running a spindle 101 137 102 138 The above VM should already be running a spindle on 103 139 `localhost:6555`. Head to http://localhost:3000/spindles and ··· 119 155 # litecli has a nicer REPL interface: 120 156 litecli /var/lib/spindle/spindle.db 121 157 ``` 158 + 159 + If for any reason you wish to disable either one of the 160 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 + `services.tangled-spindle.enable` (or 162 + `services.tangled-knot.enable`) to `false`.
+2 -2
docs/knot-hosting.md
··· 19 19 First, clone this repository: 20 20 21 21 ``` 22 - git clone https://tangled.sh/@tangled.sh/core 22 + git clone https://tangled.org/@tangled.org/core 23 23 ``` 24 24 25 25 Then, build the `knot` CLI. This is the knot administration and operation tool. ··· 130 130 131 131 You should now have a running knot server! You can finalize 132 132 your registration by hitting the `verify` button on the 133 - [/knots](https://tangled.sh/knots) page. This simply creates 133 + [/knots](https://tangled.org/knots) page. This simply creates 134 134 a record on your PDS to announce the existence of the knot. 135 135 136 136 ### custom paths
-35
docs/migrations/knot-1.7.0.md
··· 1 - # Upgrading from v1.7.0 2 - 3 - After v1.7.0, knot secrets have been deprecated. You no 4 - longer need a secret from the appview to run a knot. All 5 - authorized commands to knots are managed via [Inter-Service 6 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 7 - Knots will be read-only until upgraded. 8 - 9 - Upgrading is quite easy, in essence: 10 - 11 - - `KNOT_SERVER_SECRET` is no more, you can remove this 12 - environment variable entirely 13 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 14 - your DID. You can find your DID in the 15 - [settings](https://tangled.sh/settings) page. 16 - - Restart your knot once you have replaced the environment 17 - variable 18 - - Head to the [knot dashboard](https://tangled.sh/knots) and 19 - hit the "retry" button to verify your knot. This simply 20 - writes a `sh.tangled.knot` record to your PDS. 21 - 22 - ## Nix 23 - 24 - If you use the nix module, simply bump the flake to the 25 - latest revision, and change your config block like so: 26 - 27 - ```diff 28 - services.tangled-knot = { 29 - enable = true; 30 - server = { 31 - - secretFile = /path/to/secret; 32 - + owner = "did:plc:foo"; 33 - }; 34 - }; 35 - ```
+59
docs/migrations.md
··· 1 + # Migrations 2 + 3 + This document is laid out in reverse-chronological order. 4 + Newer migration guides are listed first, and older guides 5 + are further down the page. 6 + 7 + ## Upgrading from v1.8.x 8 + 9 + After v1.8.2, the HTTP API for knot and spindles have been 10 + deprecated and replaced with XRPC. Repositories on outdated 11 + knots will not be viewable from the appview. Upgrading is 12 + straightforward however. 13 + 14 + For knots: 15 + 16 + - Upgrade to latest tag (v1.9.0 or above) 17 + - Head to the [knot dashboard](https://tangled.org/knots) and 18 + hit the "retry" button to verify your knot 19 + 20 + For spindles: 21 + 22 + - Upgrade to latest tag (v1.9.0 or above) 23 + - Head to the [spindle 24 + dashboard](https://tangled.org/spindles) and hit the 25 + "retry" button to verify your spindle 26 + 27 + ## Upgrading from v1.7.x 28 + 29 + After v1.7.0, knot secrets have been deprecated. You no 30 + longer need a secret from the appview to run a knot. All 31 + authorized commands to knots are managed via [Inter-Service 32 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 + Knots will be read-only until upgraded. 34 + 35 + Upgrading is quite easy, in essence: 36 + 37 + - `KNOT_SERVER_SECRET` is no more, you can remove this 38 + environment variable entirely 39 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 + your DID. You can find your DID in the 41 + [settings](https://tangled.org/settings) page. 42 + - Restart your knot once you have replaced the environment 43 + variable 44 + - Head to the [knot dashboard](https://tangled.org/knots) and 45 + hit the "retry" button to verify your knot. This simply 46 + writes a `sh.tangled.knot` record to your PDS. 47 + 48 + If you use the nix module, simply bump the flake to the 49 + latest revision, and change your config block like so: 50 + 51 + ```diff 52 + services.tangled-knot = { 53 + enable = true; 54 + server = { 55 + - secretFile = /path/to/secret; 56 + + owner = "did:plc:foo"; 57 + }; 58 + }; 59 + ```
+1 -1
docs/spindle/openbao.md
··· 44 44 ### production 45 45 46 46 You would typically use a systemd service with a configuration file. Refer to 47 - [@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be 47 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 48 achieved using Nix. 49 49 50 50 Then, initialize the bao server:
+3 -3
docs/spindle/pipeline.md
··· 21 21 - `manual`: The workflow can be triggered manually. 22 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 23 24 - For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 24 + For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 25 26 26 ```yaml 27 27 when: ··· 73 73 - nodejs 74 74 - go 75 75 # custom registry 76 - git+https://tangled.sh/@example.com/my_pkg: 76 + git+https://tangled.org/@example.com/my_pkg: 77 77 - my_pkg 78 78 ``` 79 79 ··· 141 141 - nodejs 142 142 - go 143 143 # custom registry 144 - git+https://tangled.sh/@example.com/my_pkg: 144 + git+https://tangled.org/@example.com/my_pkg: 145 145 - my_pkg 146 146 147 147 environment:
+2 -2
eventconsumer/consumer.go
··· 9 9 "sync" 10 10 "time" 11 11 12 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 13 - "tangled.sh/tangled.sh/core/log" 12 + "tangled.org/core/eventconsumer/cursor" 13 + "tangled.org/core/log" 14 14 15 15 "github.com/avast/retry-go/v4" 16 16 "github.com/gorilla/websocket"
+1 -1
eventconsumer/cursor/redis.go
··· 5 5 "fmt" 6 6 "strconv" 7 7 8 - "tangled.sh/tangled.sh/core/appview/cache" 8 + "tangled.org/core/appview/cache" 9 9 ) 10 10 11 11 const (
+15
flake.lock
··· 1 1 { 2 2 "nodes": { 3 + "flake-compat": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1751685974, 7 + "narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=", 8 + "rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1", 9 + "type": "tarball", 10 + "url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz?rev=549f2762aebeff29a2e5ece7a7dc0f955281a1d1" 11 + }, 12 + "original": { 13 + "type": "tarball", 14 + "url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz" 15 + } 16 + }, 3 17 "flake-utils": { 4 18 "inputs": { 5 19 "systems": "systems" ··· 136 150 }, 137 151 "root": { 138 152 "inputs": { 153 + "flake-compat": "flake-compat", 139 154 "gomod2nix": "gomod2nix", 140 155 "htmx-src": "htmx-src", 141 156 "htmx-ws-src": "htmx-ws-src",
+7 -1
flake.nix
··· 7 7 url = "github:nix-community/gomod2nix"; 8 8 inputs.nixpkgs.follows = "nixpkgs"; 9 9 }; 10 + flake-compat = { 11 + url = "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz"; 12 + flake = false; 13 + }; 10 14 indigo = { 11 15 url = "github:oppiliappan/indigo"; 12 16 flake = false; ··· 50 54 inter-fonts-src, 51 55 sqlite-lib-src, 52 56 ibm-plex-mono-src, 57 + ... 53 58 }: let 54 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 55 60 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; ··· 146 151 nativeBuildInputs = [ 147 152 pkgs.go 148 153 pkgs.air 154 + pkgs.tilt 149 155 pkgs.gopls 150 156 pkgs.httpie 151 157 pkgs.litecli ··· 182 188 tailwind-watcher = 183 189 pkgs.writeShellScriptBin "run" 184 190 '' 185 - ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 191 + ${pkgs.tailwindcss}/bin/tailwindcss --watch=always -i input.css -o ./appview/pages/static/tw.css 186 192 ''; 187 193 in { 188 194 fmt = {
+11 -7
go.mod
··· 1 - module tangled.sh/tangled.sh/core 1 + module tangled.org/core 2 2 3 3 go 1.24.4 4 4 ··· 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 ··· 21 21 github.com/go-chi/chi/v5 v5.2.0 22 22 github.com/go-enry/go-enry/v2 v2.9.2 23 23 github.com/go-git/go-git/v5 v5.14.0 24 + github.com/goki/freetype v1.0.5 24 25 github.com/google/uuid v1.6.0 25 26 github.com/gorilla/feeds v1.2.0 26 27 github.com/gorilla/sessions v1.4.0 ··· 36 37 github.com/redis/go-redis/v9 v9.7.3 37 38 github.com/resend/resend-go/v2 v2.15.0 38 39 github.com/sethvargo/go-envconfig v1.1.0 40 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 41 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 39 42 github.com/stretchr/testify v1.10.0 40 43 github.com/urfave/cli/v3 v3.3.3 41 44 github.com/whyrusleeping/cbor-gen v0.3.1 42 45 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 46 + github.com/yuin/goldmark v1.7.13 44 47 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 48 golang.org/x/crypto v0.40.0 49 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 50 + golang.org/x/image v0.31.0 46 51 golang.org/x/net v0.42.0 47 - golang.org/x/sync v0.16.0 52 + golang.org/x/sync v0.17.0 48 53 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 54 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 55 ) 52 56 53 57 require ( ··· 156 160 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 157 161 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 162 github.com/wyatt915/treeblood v0.1.15 // indirect 163 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 159 164 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 160 165 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 161 166 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 168 173 go.uber.org/atomic v1.11.0 // indirect 169 174 go.uber.org/multierr v1.11.0 // indirect 170 175 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 176 golang.org/x/sys v0.34.0 // indirect 173 - golang.org/x/text v0.27.0 // indirect 177 + golang.org/x/text v0.29.0 // indirect 174 178 golang.org/x/time v0.12.0 // indirect 175 179 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 176 180 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+18 -12
go.sum
··· 23 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 26 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 27 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 28 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 136 136 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 137 137 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 138 138 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 139 + github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= 140 + github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= 139 141 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 140 142 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 141 143 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 243 245 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 244 246 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 245 247 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 246 - github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 247 - github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 248 248 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 249 249 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 250 250 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 399 399 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 400 400 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 401 401 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 402 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 403 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 404 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= 405 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= 402 406 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 403 407 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 404 408 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 436 440 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 441 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 442 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 - github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 - github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 443 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 444 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 445 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 446 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 447 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 448 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 443 449 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 444 450 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 445 451 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 489 495 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 490 496 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 491 497 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 498 + golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= 499 + golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= 492 500 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 493 501 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 494 502 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 528 536 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 529 537 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 530 538 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 531 - golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 532 - golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 539 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 540 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 533 541 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 534 542 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 535 543 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 583 591 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 584 592 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 585 593 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 586 - golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 587 - golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 594 + golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 595 + golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 588 596 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 589 597 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 590 598 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 652 660 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 661 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 662 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 655 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 657 663 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 664 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+2 -2
guard/guard.go
··· 15 15 "github.com/bluesky-social/indigo/atproto/identity" 16 16 securejoin "github.com/cyphar/filepath-securejoin" 17 17 "github.com/urfave/cli/v3" 18 - "tangled.sh/tangled.sh/core/idresolver" 19 - "tangled.sh/tangled.sh/core/log" 18 + "tangled.org/core/idresolver" 19 + "tangled.org/core/log" 20 20 ) 21 21 22 22 func Command() *cli.Command {
+73 -17
input.css
··· 90 90 } 91 91 92 92 label { 93 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 93 + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 94 } 95 95 input { 96 96 @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; ··· 134 134 } 135 135 136 136 .prose hr { 137 - @apply my-2; 137 + @apply my-2; 138 138 } 139 139 140 140 .prose li:has(input) { 141 - @apply list-none; 141 + @apply list-none; 142 142 } 143 143 144 144 .prose ul:has(input) { 145 - @apply pl-2; 145 + @apply pl-2; 146 146 } 147 147 148 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 149 + @apply no-underline mx-2 opacity-0; 150 150 } 151 151 152 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 153 + @apply opacity-70; 154 154 } 155 155 156 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 157 + @apply opacity-70; 158 158 } 159 159 160 160 .prose a.footnote-backref { 161 - @apply no-underline; 161 + @apply no-underline; 162 162 } 163 163 164 164 .prose li { 165 - @apply my-0 py-0; 165 + @apply my-0 py-0; 166 166 } 167 167 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 168 + .prose ul, 169 + .prose ol { 170 + @apply my-1 py-0; 170 171 } 171 172 172 173 .prose img { ··· 176 177 } 177 178 178 179 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 180 + @apply inline-block my-0 mb-1 mx-1; 180 181 } 181 182 182 183 .prose input[type="checkbox"] { 183 184 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 185 } 186 + 187 + /* Base callout */ 188 + details[data-callout] { 189 + @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; 190 + } 191 + 192 + details[data-callout] > summary { 193 + @apply font-bold cursor-pointer mb-1; 194 + } 195 + 196 + details[data-callout] > .callout-content { 197 + @apply text-sm leading-snug; 198 + } 199 + 200 + /* Note (blue) */ 201 + details[data-callout="note" i] { 202 + @apply border-blue-400 dark:border-blue-500; 203 + } 204 + details[data-callout="note" i] > summary { 205 + @apply text-blue-700 dark:text-blue-400; 206 + } 207 + 208 + /* Important (purple) */ 209 + details[data-callout="important" i] { 210 + @apply border-purple-400 dark:border-purple-500; 211 + } 212 + details[data-callout="important" i] > summary { 213 + @apply text-purple-700 dark:text-purple-400; 214 + } 215 + 216 + /* Warning (yellow) */ 217 + details[data-callout="warning" i] { 218 + @apply border-yellow-400 dark:border-yellow-500; 219 + } 220 + details[data-callout="warning" i] > summary { 221 + @apply text-yellow-700 dark:text-yellow-400; 222 + } 223 + 224 + /* Caution (red) */ 225 + details[data-callout="caution" i] { 226 + @apply border-red-400 dark:border-red-500; 227 + } 228 + details[data-callout="caution" i] > summary { 229 + @apply text-red-700 dark:text-red-400; 230 + } 231 + 232 + /* Tip (green) */ 233 + details[data-callout="tip" i] { 234 + @apply border-green-400 dark:border-green-500; 235 + } 236 + details[data-callout="tip" i] > summary { 237 + @apply text-green-700 dark:text-green-400; 238 + } 239 + 240 + /* Optional: hide the disclosure arrow like GitHub */ 241 + details[data-callout] > summary::-webkit-details-marker { 242 + display: none; 243 + } 185 244 } 186 245 @layer utilities { 187 246 .error { ··· 228 287 } 229 288 /* LineHighlight */ 230 289 .chroma .hl { 231 - background-color: #bcc0cc; 290 + @apply bg-amber-400/30 dark:bg-amber-500/20; 232 291 } 292 + 233 293 /* LineNumbersTable */ 234 294 .chroma .lnt { 235 295 white-space: pre; ··· 864 924 text-decoration: underline; 865 925 } 866 926 } 867 - 868 - .chroma .line:has(.ln:target) { 869 - @apply bg-amber-400/30 dark:bg-amber-500/20; 870 - }
+1 -1
jetstream/jetstream.go
··· 13 13 "github.com/bluesky-social/jetstream/pkg/client" 14 14 "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 15 15 "github.com/bluesky-social/jetstream/pkg/models" 16 - "tangled.sh/tangled.sh/core/log" 16 + "tangled.org/core/log" 17 17 ) 18 18 19 19 type DB interface {
+1 -1
keyfetch/keyfetch.go
··· 10 10 "strings" 11 11 12 12 "github.com/urfave/cli/v3" 13 - "tangled.sh/tangled.sh/core/log" 13 + "tangled.org/core/log" 14 14 ) 15 15 16 16 func Command() *cli.Command {
-285
knotclient/unsigned.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "strconv" 12 - "time" 13 - 14 - "tangled.sh/tangled.sh/core/types" 15 - ) 16 - 17 - type UnsignedClient struct { 18 - Url *url.URL 19 - client *http.Client 20 - } 21 - 22 - func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) { 23 - client := &http.Client{ 24 - Timeout: 5 * time.Second, 25 - } 26 - 27 - scheme := "https" 28 - if dev { 29 - scheme = "http" 30 - } 31 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 32 - if err != nil { 33 - return nil, err 34 - } 35 - 36 - unsignedClient := &UnsignedClient{ 37 - client: client, 38 - Url: url, 39 - } 40 - 41 - return unsignedClient, nil 42 - } 43 - 44 - func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) { 45 - reqUrl := us.Url.JoinPath(endpoint) 46 - 47 - // add query parameters 48 - if query != nil { 49 - reqUrl.RawQuery = query.Encode() 50 - } 51 - 52 - return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body)) 53 - } 54 - 55 - func do[T any](us *UnsignedClient, req *http.Request) (*T, error) { 56 - resp, err := us.client.Do(req) 57 - if err != nil { 58 - return nil, err 59 - } 60 - defer resp.Body.Close() 61 - 62 - body, err := io.ReadAll(resp.Body) 63 - if err != nil { 64 - log.Printf("Error reading response body: %v", err) 65 - return nil, err 66 - } 67 - 68 - var result T 69 - err = json.Unmarshal(body, &result) 70 - if err != nil { 71 - log.Printf("Error unmarshalling response body: %v", err) 72 - return nil, err 73 - } 74 - 75 - return &result, nil 76 - } 77 - 78 - func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*types.RepoIndexResponse, error) { 79 - const ( 80 - Method = "GET" 81 - ) 82 - 83 - endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref) 84 - if ref == "" { 85 - endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName) 86 - } 87 - 88 - req, err := us.newRequest(Method, endpoint, nil, nil) 89 - if err != nil { 90 - return nil, err 91 - } 92 - 93 - return do[types.RepoIndexResponse](us, req) 94 - } 95 - 96 - func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*types.RepoLogResponse, error) { 97 - const ( 98 - Method = "GET" 99 - ) 100 - 101 - endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref)) 102 - 103 - query := url.Values{} 104 - query.Add("page", strconv.Itoa(page)) 105 - query.Add("per_page", strconv.Itoa(60)) 106 - 107 - req, err := us.newRequest(Method, endpoint, query, nil) 108 - if err != nil { 109 - return nil, err 110 - } 111 - 112 - return do[types.RepoLogResponse](us, req) 113 - } 114 - 115 - func (us *UnsignedClient) Branches(ownerDid, repoName string) (*types.RepoBranchesResponse, error) { 116 - const ( 117 - Method = "GET" 118 - ) 119 - 120 - endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName) 121 - 122 - req, err := us.newRequest(Method, endpoint, nil, nil) 123 - if err != nil { 124 - return nil, err 125 - } 126 - 127 - return do[types.RepoBranchesResponse](us, req) 128 - } 129 - 130 - func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) { 131 - const ( 132 - Method = "GET" 133 - ) 134 - 135 - endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName) 136 - 137 - req, err := us.newRequest(Method, endpoint, nil, nil) 138 - if err != nil { 139 - return nil, err 140 - } 141 - 142 - return do[types.RepoTagsResponse](us, req) 143 - } 144 - 145 - func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*types.RepoBranchResponse, error) { 146 - const ( 147 - Method = "GET" 148 - ) 149 - 150 - endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch)) 151 - 152 - req, err := us.newRequest(Method, endpoint, nil, nil) 153 - if err != nil { 154 - return nil, err 155 - } 156 - 157 - return do[types.RepoBranchResponse](us, req) 158 - } 159 - 160 - func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) { 161 - const ( 162 - Method = "GET" 163 - ) 164 - 165 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 166 - 167 - req, err := us.newRequest(Method, endpoint, nil, nil) 168 - if err != nil { 169 - return nil, err 170 - } 171 - 172 - resp, err := us.client.Do(req) 173 - if err != nil { 174 - return nil, err 175 - } 176 - defer resp.Body.Close() 177 - 178 - var defaultBranch types.RepoDefaultBranchResponse 179 - if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil { 180 - return nil, err 181 - } 182 - 183 - return &defaultBranch, nil 184 - } 185 - 186 - func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) { 187 - const ( 188 - Method = "GET" 189 - Endpoint = "/capabilities" 190 - ) 191 - 192 - req, err := us.newRequest(Method, Endpoint, nil, nil) 193 - if err != nil { 194 - return nil, err 195 - } 196 - 197 - resp, err := us.client.Do(req) 198 - if err != nil { 199 - return nil, err 200 - } 201 - defer resp.Body.Close() 202 - 203 - var capabilities types.Capabilities 204 - if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil { 205 - return nil, err 206 - } 207 - 208 - return &capabilities, nil 209 - } 210 - 211 - func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) { 212 - const ( 213 - Method = "GET" 214 - ) 215 - 216 - endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2)) 217 - 218 - req, err := us.newRequest(Method, endpoint, nil, nil) 219 - if err != nil { 220 - return nil, fmt.Errorf("Failed to create request.") 221 - } 222 - 223 - compareResp, err := us.client.Do(req) 224 - if err != nil { 225 - return nil, fmt.Errorf("Failed to create request.") 226 - } 227 - defer compareResp.Body.Close() 228 - 229 - switch compareResp.StatusCode { 230 - case 404: 231 - case 400: 232 - return nil, fmt.Errorf("Branch comparisons not supported on this knot.") 233 - } 234 - 235 - respBody, err := io.ReadAll(compareResp.Body) 236 - if err != nil { 237 - log.Println("failed to compare across branches") 238 - return nil, fmt.Errorf("Failed to compare branches.") 239 - } 240 - defer compareResp.Body.Close() 241 - 242 - var formatPatchResponse types.RepoFormatPatchResponse 243 - err = json.Unmarshal(respBody, &formatPatchResponse) 244 - if err != nil { 245 - log.Println("failed to unmarshal format-patch response", err) 246 - return nil, fmt.Errorf("failed to compare branches.") 247 - } 248 - 249 - return &formatPatchResponse, nil 250 - } 251 - 252 - func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 253 - const ( 254 - Method = "GET" 255 - ) 256 - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 257 - 258 - req, err := s.newRequest(Method, endpoint, nil, nil) 259 - if err != nil { 260 - return nil, err 261 - } 262 - 263 - resp, err := s.client.Do(req) 264 - if err != nil { 265 - return nil, err 266 - } 267 - 268 - var result types.RepoLanguageResponse 269 - if resp.StatusCode != http.StatusOK { 270 - log.Println("failed to calculate languages", resp.Status) 271 - return &types.RepoLanguageResponse{}, nil 272 - } 273 - 274 - body, err := io.ReadAll(resp.Body) 275 - if err != nil { 276 - return nil, err 277 - } 278 - 279 - err = json.Unmarshal(body, &result) 280 - if err != nil { 281 - return nil, err 282 - } 283 - 284 - return &result, nil 285 - }
+8 -1
knotserver/config/config.go
··· 27 27 Dev bool `env:"DEV, default=false"` 28 28 } 29 29 30 + type Git struct { 31 + // user name & email used as committer 32 + UserName string `env:"USER_NAME, default=Tangled"` 33 + UserEmail string `env:"USER_EMAIL, default=noreply@tangled.sh"` 34 + } 35 + 30 36 func (s Server) Did() syntax.DID { 31 37 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 32 38 } ··· 34 40 type Config struct { 35 41 Repo Repo `env:",prefix=KNOT_REPO_"` 36 42 Server Server `env:",prefix=KNOT_SERVER_"` 37 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 43 + Git Git `env:",prefix=KNOT_GIT_"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 38 45 } 39 46 40 47 func Load(ctx context.Context) (*Config, error) {
+1 -1
knotserver/db/events.go
··· 4 4 "fmt" 5 5 "time" 6 6 7 - "tangled.sh/tangled.sh/core/notifier" 7 + "tangled.org/core/notifier" 8 8 ) 9 9 10 10 type Event struct {
+41 -1
knotserver/db/pubkeys.go
··· 1 1 package db 2 2 3 3 import ( 4 + "strconv" 4 5 "time" 5 6 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 + "tangled.org/core/api/tangled" 7 8 ) 8 9 9 10 type PublicKey struct { ··· 99 100 100 101 return keys, nil 101 102 } 103 + 104 + func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) { 105 + var keys []PublicKey 106 + 107 + offset := 0 108 + if cursor != "" { 109 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 110 + offset = o 111 + } 112 + } 113 + 114 + query := `select key, did, created from public_keys order by created desc limit ? offset ?` 115 + rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results 116 + if err != nil { 117 + return nil, "", err 118 + } 119 + defer rows.Close() 120 + 121 + for rows.Next() { 122 + var publicKey PublicKey 123 + if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil { 124 + return nil, "", err 125 + } 126 + keys = append(keys, publicKey) 127 + } 128 + 129 + if err := rows.Err(); err != nil { 130 + return nil, "", err 131 + } 132 + 133 + // check if there are more results for pagination 134 + var nextCursor string 135 + if len(keys) > limit { 136 + keys = keys[:limit] // remove the extra item 137 + nextCursor = strconv.Itoa(offset + limit) 138 + } 139 + 140 + return keys, nextCursor, nil 141 + }
+2 -2
knotserver/events.go
··· 15 15 WriteBufferSize: 1024, 16 16 } 17 17 18 - func (h *Handle) Events(w http.ResponseWriter, r *http.Request) { 18 + func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 19 l := h.l.With("handler", "OpLog") 20 20 l.Debug("received new connection") 21 21 ··· 83 83 } 84 84 } 85 85 86 - func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error { 86 + func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error { 87 87 events, err := h.db.GetEvents(*cursor) 88 88 if err != nil { 89 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
-48
knotserver/file.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "bytes" 5 - "io" 6 - "log/slog" 7 - "net/http" 8 - "strings" 9 - 10 - "tangled.sh/tangled.sh/core/types" 11 - ) 12 - 13 - func countLines(r io.Reader) (int, error) { 14 - buf := make([]byte, 32*1024) 15 - bufLen := 0 16 - count := 0 17 - nl := []byte{'\n'} 18 - 19 - for { 20 - c, err := r.Read(buf) 21 - if c > 0 { 22 - bufLen += c 23 - } 24 - count += bytes.Count(buf[:c], nl) 25 - 26 - switch { 27 - case err == io.EOF: 28 - /* handle last line not having a newline at the end */ 29 - if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 30 - count++ 31 - } 32 - return count, nil 33 - case err != nil: 34 - return 0, err 35 - } 36 - } 37 - } 38 - 39 - func (h *Handle) showFile(resp types.RepoBlobResponse, w http.ResponseWriter, l *slog.Logger) { 40 - lc, err := countLines(strings.NewReader(resp.Contents)) 41 - if err != nil { 42 - // Non-fatal, we'll just skip showing line numbers in the template. 43 - l.Warn("counting lines", "error", err) 44 - } 45 - 46 - resp.Lines = lc 47 - writeJSON(w, resp) 48 - }
+6 -1
knotserver/git/branch.go
··· 9 9 10 10 "github.com/go-git/go-git/v5/plumbing" 11 11 "github.com/go-git/go-git/v5/plumbing/object" 12 - "tangled.sh/tangled.sh/core/types" 12 + "tangled.org/core/types" 13 13 ) 14 14 15 15 func (g *GitRepo) Branches() ([]types.Branch, error) { ··· 110 110 slices.Reverse(branches) 111 111 return branches, nil 112 112 } 113 + 114 + func (g *GitRepo) DeleteBranch(branch string) error { 115 + ref := plumbing.NewBranchReferenceName(branch) 116 + return g.r.Storer.RemoveReference(ref) 117 + }
+2 -2
knotserver/git/diff.go
··· 12 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 13 "github.com/go-git/go-git/v5/plumbing" 14 14 "github.com/go-git/go-git/v5/plumbing/object" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 - "tangled.sh/tangled.sh/core/types" 15 + "tangled.org/core/patchutil" 16 + "tangled.org/core/types" 17 17 ) 18 18 19 19 func (g *GitRepo) Diff() (*types.NiceDiff, error) {
+11 -103
knotserver/git/git.go
··· 27 27 h plumbing.Hash 28 28 } 29 29 30 - type TagList struct { 31 - refs []*TagReference 32 - r *git.Repository 33 - } 34 - 35 - // TagReference is used to list both tag and non-annotated tags. 36 - // Non-annotated tags should only contains a reference. 37 - // Annotated tags should contain its reference and its tag information. 38 - type TagReference struct { 39 - ref *plumbing.Reference 40 - tag *object.Tag 41 - } 42 - 43 30 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 44 31 // to tar WriteHeader 45 32 type infoWrapper struct { ··· 50 37 isDir bool 51 38 } 52 39 53 - func (self *TagList) Len() int { 54 - return len(self.refs) 55 - } 56 - 57 - func (self *TagList) Swap(i, j int) { 58 - self.refs[i], self.refs[j] = self.refs[j], self.refs[i] 59 - } 60 - 61 - // sorting tags in reverse chronological order 62 - func (self *TagList) Less(i, j int) bool { 63 - var dateI time.Time 64 - var dateJ time.Time 65 - 66 - if self.refs[i].tag != nil { 67 - dateI = self.refs[i].tag.Tagger.When 68 - } else { 69 - c, err := self.r.CommitObject(self.refs[i].ref.Hash()) 70 - if err != nil { 71 - dateI = time.Now() 72 - } else { 73 - dateI = c.Committer.When 74 - } 75 - } 76 - 77 - if self.refs[j].tag != nil { 78 - dateJ = self.refs[j].tag.Tagger.When 79 - } else { 80 - c, err := self.r.CommitObject(self.refs[j].ref.Hash()) 81 - if err != nil { 82 - dateJ = time.Now() 83 - } else { 84 - dateJ = c.Committer.When 85 - } 86 - } 87 - 88 - return dateI.After(dateJ) 89 - } 90 - 91 40 func Open(path string, ref string) (*GitRepo, error) { 92 41 var err error 93 42 g := GitRepo{path: path} ··· 122 71 return &g, nil 123 72 } 124 73 74 + // re-open a repository and update references 75 + func (g *GitRepo) Refresh() error { 76 + refreshed, err := PlainOpen(g.path) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + *g = *refreshed 82 + return nil 83 + } 84 + 125 85 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 126 86 commits := []*object.Commit{} 127 87 ··· 171 131 return g.r.CommitObject(h) 172 132 } 173 133 174 - func (g *GitRepo) LastCommit() (*object.Commit, error) { 175 - c, err := g.r.CommitObject(g.h) 176 - if err != nil { 177 - return nil, fmt.Errorf("last commit: %w", err) 178 - } 179 - return c, nil 180 - } 181 - 182 134 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 183 135 c, err := g.r.CommitObject(g.h) 184 136 if err != nil { ··· 211 163 } 212 164 213 165 return buf.Bytes(), nil 214 - } 215 - 216 - func (g *GitRepo) FileContent(path string) (string, error) { 217 - c, err := g.r.CommitObject(g.h) 218 - if err != nil { 219 - return "", fmt.Errorf("commit object: %w", err) 220 - } 221 - 222 - tree, err := c.Tree() 223 - if err != nil { 224 - return "", fmt.Errorf("file tree: %w", err) 225 - } 226 - 227 - file, err := tree.File(path) 228 - if err != nil { 229 - return "", err 230 - } 231 - 232 - isbin, _ := file.IsBinary() 233 - 234 - if !isbin { 235 - return file.Contents() 236 - } else { 237 - return "", ErrBinaryFile 238 - } 239 166 } 240 167 241 168 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 410 337 func (i *infoWrapper) Sys() any { 411 338 return nil 412 339 } 413 - 414 - func (t *TagReference) Name() string { 415 - return t.ref.Name().Short() 416 - } 417 - 418 - func (t *TagReference) Message() string { 419 - if t.tag != nil { 420 - return t.tag.Message 421 - } 422 - return "" 423 - } 424 - 425 - func (t *TagReference) TagObject() *object.Tag { 426 - return t.tag 427 - } 428 - 429 - func (t *TagReference) Hash() plumbing.Hash { 430 - return t.ref.Hash() 431 - }
+4 -1
knotserver/git/language.go
··· 3 3 import ( 4 4 "context" 5 5 "path" 6 + "strings" 6 7 7 8 "github.com/go-enry/go-enry/v2" 8 9 "github.com/go-git/go-git/v5/plumbing/object" ··· 20 21 return nil 21 22 } 22 23 23 - if enry.IsGenerated(filepath, content) { 24 + if enry.IsGenerated(filepath, content) || 25 + enry.IsBinary(content) || 26 + strings.HasSuffix(filepath, "bun.lock") { 24 27 return nil 25 28 } 26 29
+177 -78
knotserver/git/merge.go
··· 4 4 "bytes" 5 5 "crypto/sha256" 6 6 "fmt" 7 + "log" 7 8 "os" 8 9 "os/exec" 9 10 "regexp" ··· 12 13 "github.com/dgraph-io/ristretto" 13 14 "github.com/go-git/go-git/v5" 14 15 "github.com/go-git/go-git/v5/plumbing" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 + "tangled.org/core/patchutil" 17 + "tangled.org/core/types" 16 18 ) 17 19 18 20 type MergeCheckCache struct { ··· 33 35 mergeCheckCache = MergeCheckCache{cache} 34 36 } 35 37 36 - func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string { 38 + func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 37 39 sep := byte(':') 38 40 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 39 41 return fmt.Sprintf("%x", hash) ··· 50 52 } 51 53 } 52 54 53 - func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) { 55 + func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 54 56 key := m.cacheKey(g, patch, targetBranch) 55 57 val := m.cacheVal(mergeCheck) 56 58 m.cache.Set(key, val, 0) 57 59 } 58 60 59 - func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) { 61 + func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 60 62 key := m.cacheKey(g, patch, targetBranch) 61 63 if val, ok := m.cache.Get(key); ok { 62 64 if val == struct{}{} { ··· 86 88 87 89 // MergeOptions specifies the configuration for a merge operation 88 90 type MergeOptions struct { 89 - CommitMessage string 90 - CommitBody string 91 - AuthorName string 92 - AuthorEmail string 93 - FormatPatch bool 91 + CommitMessage string 92 + CommitBody string 93 + AuthorName string 94 + AuthorEmail string 95 + CommitterName string 96 + CommitterEmail string 97 + FormatPatch bool 94 98 } 95 99 96 100 func (e ErrMerge) Error() string { ··· 103 107 return fmt.Sprintf("merge failed: %s", e.Message) 104 108 } 105 109 106 - func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 110 + func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) { 107 111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 108 112 if err != nil { 109 113 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 110 114 } 111 115 112 - if _, err := tmpFile.Write(patchData); err != nil { 116 + if _, err := tmpFile.Write([]byte(patchData)); err != nil { 113 117 tmpFile.Close() 114 118 os.Remove(tmpFile.Name()) 115 119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 143 147 return tmpDir, nil 144 148 } 145 149 146 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error { 150 + func (g *GitRepo) checkPatch(tmpDir, patchFile string) error { 147 151 var stderr bytes.Buffer 148 - var cmd *exec.Cmd 149 152 150 - if checkOnly { 151 - cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 152 - } else { 153 - // if patch is a format-patch, apply using 'git am' 154 - if opts.FormatPatch { 155 - amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile) 156 - amCmd.Stderr = &stderr 157 - if err := amCmd.Run(); err != nil { 158 - return fmt.Errorf("patch application failed: %s", stderr.String()) 159 - } 160 - return nil 153 + cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 154 + cmd.Stderr = &stderr 155 + 156 + if err := cmd.Run(); err != nil { 157 + conflicts := parseGitApplyErrors(stderr.String()) 158 + return &ErrMerge{ 159 + Message: "patch cannot be applied cleanly", 160 + Conflicts: conflicts, 161 + HasConflict: len(conflicts) > 0, 162 + OtherError: err, 161 163 } 164 + } 165 + return nil 166 + } 162 167 163 - // else, apply using 'git apply' and commit it manually 164 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 165 - if opts != nil { 166 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 167 - applyCmd.Stderr = &stderr 168 - if err := applyCmd.Run(); err != nil { 169 - return fmt.Errorf("patch application failed: %s", stderr.String()) 170 - } 168 + func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 169 + var stderr bytes.Buffer 170 + var cmd *exec.Cmd 171 171 172 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 173 - if err := stageCmd.Run(); err != nil { 174 - return fmt.Errorf("failed to stage changes: %w", err) 175 - } 172 + // configure default git user before merge 173 + exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 + exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 + exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 176 176 177 - commitArgs := []string{"-C", tmpDir, "commit"} 177 + // if patch is a format-patch, apply using 'git am' 178 + if opts.FormatPatch { 179 + return g.applyMailbox(patchData) 180 + } 178 181 179 - // Set author if provided 180 - authorName := opts.AuthorName 181 - authorEmail := opts.AuthorEmail 182 + // else, apply using 'git apply' and commit it manually 183 + applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 184 + applyCmd.Stderr = &stderr 185 + if err := applyCmd.Run(); err != nil { 186 + return fmt.Errorf("patch application failed: %s", stderr.String()) 187 + } 182 188 183 - if authorEmail == "" { 184 - authorEmail = "noreply@tangled.sh" 185 - } 189 + stageCmd := exec.Command("git", "-C", g.path, "add", ".") 190 + if err := stageCmd.Run(); err != nil { 191 + return fmt.Errorf("failed to stage changes: %w", err) 192 + } 186 193 187 - if authorName == "" { 188 - authorName = "Tangled" 189 - } 194 + commitArgs := []string{"-C", g.path, "commit"} 190 195 191 - if authorName != "" { 192 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 193 - } 196 + // Set author if provided 197 + authorName := opts.AuthorName 198 + authorEmail := opts.AuthorEmail 194 199 195 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 200 + if authorName != "" && authorEmail != "" { 201 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 202 + } 203 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 196 204 197 - if opts.CommitBody != "" { 198 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 199 - } 205 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 206 + 207 + if opts.CommitBody != "" { 208 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 209 + } 210 + 211 + cmd = exec.Command("git", commitArgs...) 212 + 213 + cmd.Stderr = &stderr 214 + 215 + if err := cmd.Run(); err != nil { 216 + return fmt.Errorf("patch application failed: %s", stderr.String()) 217 + } 218 + 219 + return nil 220 + } 221 + 222 + func (g *GitRepo) applyMailbox(patchData string) error { 223 + fps, err := patchutil.ExtractPatches(patchData) 224 + if err != nil { 225 + return fmt.Errorf("failed to extract patches: %w", err) 226 + } 200 227 201 - cmd = exec.Command("git", commitArgs...) 202 - } else { 203 - // If no commit message specified, use git-am which automatically creates a commit 204 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 228 + // apply each patch one by one 229 + // update the newly created commit object to add the change-id header 230 + total := len(fps) 231 + for i, p := range fps { 232 + newCommit, err := g.applySingleMailbox(p) 233 + if err != nil { 234 + return err 205 235 } 236 + 237 + log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 206 238 } 207 239 240 + return nil 241 + } 242 + 243 + func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 244 + tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw) 245 + if err != nil { 246 + return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 247 + } 248 + 249 + var stderr bytes.Buffer 250 + cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 208 251 cmd.Stderr = &stderr 209 252 253 + head, err := g.r.Head() 254 + if err != nil { 255 + return plumbing.ZeroHash, err 256 + } 257 + log.Println("head before apply", head.Hash().String()) 258 + 210 259 if err := cmd.Run(); err != nil { 211 - if checkOnly { 212 - conflicts := parseGitApplyErrors(stderr.String()) 213 - return &ErrMerge{ 214 - Message: "patch cannot be applied cleanly", 215 - Conflicts: conflicts, 216 - HasConflict: len(conflicts) > 0, 217 - OtherError: err, 218 - } 260 + return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String()) 261 + } 262 + 263 + if err := g.Refresh(); err != nil { 264 + return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 265 + } 266 + 267 + head, err = g.r.Head() 268 + if err != nil { 269 + return plumbing.ZeroHash, err 270 + } 271 + log.Println("head after apply", head.Hash().String()) 272 + 273 + newHash := head.Hash() 274 + if changeId, err := singlePatch.ChangeId(); err != nil { 275 + // no change ID 276 + } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 277 + return plumbing.ZeroHash, err 278 + } else { 279 + newHash = updatedHash 280 + } 281 + 282 + return newHash, nil 283 + } 284 + 285 + func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 286 + log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 287 + obj, err := g.r.CommitObject(hash) 288 + if err != nil { 289 + return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 290 + } 291 + 292 + // write the change-id header 293 + obj.ExtraHeaders["change-id"] = []byte(changeId) 294 + 295 + // create a new object 296 + dest := g.r.Storer.NewEncodedObject() 297 + if err := obj.Encode(dest); err != nil { 298 + return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 299 + } 300 + 301 + // store the new object 302 + newHash, err := g.r.Storer.SetEncodedObject(dest) 303 + if err != nil { 304 + return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 305 + } 306 + 307 + log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 308 + 309 + // find the branch that HEAD is pointing to 310 + ref, err := g.r.Head() 311 + if err != nil { 312 + return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 313 + } 314 + 315 + // and update that branch to point to new commit 316 + if ref.Name().IsBranch() { 317 + err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 318 + if err != nil { 319 + return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 219 320 } 220 - return fmt.Errorf("patch application failed: %s", stderr.String()) 221 321 } 222 322 223 - return nil 323 + // new hash of commit 324 + return newHash, nil 224 325 } 225 326 226 - func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 327 + func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error { 227 328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 228 329 return val 229 330 } 230 - 231 - var opts MergeOptions 232 - opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 233 331 234 332 patchFile, err := g.createTempFileWithPatch(patchData) 235 333 if err != nil { ··· 249 347 } 250 348 defer os.RemoveAll(tmpDir) 251 349 252 - result := g.applyPatch(tmpDir, patchFile, true, &opts) 350 + result := g.checkPatch(tmpDir, patchFile) 253 351 mergeCheckCache.Set(g, patchData, targetBranch, result) 254 352 return result 255 353 } 256 354 257 - func (g *GitRepo) Merge(patchData []byte, targetBranch string) error { 258 - return g.MergeWithOptions(patchData, targetBranch, nil) 259 - } 260 - 261 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error { 355 + func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 262 356 patchFile, err := g.createTempFileWithPatch(patchData) 263 357 if err != nil { 264 358 return &ErrMerge{ ··· 277 371 } 278 372 defer os.RemoveAll(tmpDir) 279 373 280 - if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil { 374 + tmpRepo, err := PlainOpen(tmpDir) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 281 380 return err 282 381 } 283 382
+1 -1
knotserver/git/post_receive.go
··· 9 9 "strings" 10 10 "time" 11 11 12 - "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.org/core/api/tangled" 13 13 14 14 "github.com/go-git/go-git/v5/plumbing" 15 15 )
+1 -3
knotserver/git/tag.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "slices" 6 5 "strconv" 7 6 "strings" 8 7 "time" ··· 35 34 outFormat.WriteString("") 36 35 outFormat.WriteString(recordSeparator) 37 36 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 39 38 if err != nil { 40 39 return nil, fmt.Errorf("failed to get tags: %w", err) 41 40 } ··· 94 93 tags = append(tags, tag) 95 94 } 96 95 97 - slices.Reverse(tags) 98 96 return tags, nil 99 97 }
+1 -1
knotserver/git/tree.go
··· 8 8 "time" 9 9 10 10 "github.com/go-git/go-git/v5/plumbing/object" 11 - "tangled.sh/tangled.sh/core/types" 11 + "tangled.org/core/types" 12 12 ) 13 13 14 14 func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) {
+5 -5
knotserver/git.go
··· 10 10 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 12 "github.com/go-chi/chi/v5" 13 - "tangled.sh/tangled.sh/core/knotserver/git/service" 13 + "tangled.org/core/knotserver/git/service" 14 14 ) 15 15 16 - func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) { 16 + func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 17 did := chi.URLParam(r, "did") 18 18 name := chi.URLParam(r, "name") 19 19 repoName, err := securejoin.SecureJoin(did, name) ··· 56 56 } 57 57 } 58 58 59 - func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 59 + func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 60 did := chi.URLParam(r, "did") 61 61 name := chi.URLParam(r, "name") 62 62 repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 105 105 } 106 106 } 107 107 108 - func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) { 108 + func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 109 did := chi.URLParam(r, "did") 110 110 name := chi.URLParam(r, "name") 111 111 _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 118 118 d.RejectPush(w, r, name) 119 119 } 120 120 121 - func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 121 + func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 122 // A text/plain response will cause git to print each line of the body 123 123 // prefixed with "remote: ". 124 124 w.Header().Set("content-type", "text/plain; charset=UTF-8")
-1069
knotserver/handler.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "compress/gzip" 5 - "context" 6 - "crypto/sha256" 7 - "encoding/json" 8 - "errors" 9 - "fmt" 10 - "log" 11 - "net/http" 12 - "net/url" 13 - "path/filepath" 14 - "strconv" 15 - "strings" 16 - "sync" 17 - "time" 18 - 19 - securejoin "github.com/cyphar/filepath-securejoin" 20 - "github.com/gliderlabs/ssh" 21 - "github.com/go-chi/chi/v5" 22 - "github.com/go-git/go-git/v5/plumbing" 23 - "github.com/go-git/go-git/v5/plumbing/object" 24 - "tangled.sh/tangled.sh/core/knotserver/db" 25 - "tangled.sh/tangled.sh/core/knotserver/git" 26 - "tangled.sh/tangled.sh/core/types" 27 - ) 28 - 29 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 30 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 31 - } 32 - 33 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 34 - w.Header().Set("Content-Type", "application/json") 35 - 36 - capabilities := map[string]any{ 37 - "pull_requests": map[string]any{ 38 - "format_patch": true, 39 - "patch_submissions": true, 40 - "branch_submissions": true, 41 - "fork_submissions": true, 42 - }, 43 - "xrpc": true, 44 - } 45 - 46 - jsonData, err := json.Marshal(capabilities) 47 - if err != nil { 48 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 49 - return 50 - } 51 - 52 - w.Write(jsonData) 53 - } 54 - 55 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 56 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 57 - l := h.l.With("path", path, "handler", "RepoIndex") 58 - ref := chi.URLParam(r, "ref") 59 - ref, _ = url.PathUnescape(ref) 60 - 61 - gr, err := git.Open(path, ref) 62 - if err != nil { 63 - plain, err2 := git.PlainOpen(path) 64 - if err2 != nil { 65 - l.Error("opening repo", "error", err2.Error()) 66 - notFound(w) 67 - return 68 - } 69 - branches, _ := plain.Branches() 70 - 71 - log.Println(err) 72 - 73 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 74 - resp := types.RepoIndexResponse{ 75 - IsEmpty: true, 76 - Branches: branches, 77 - } 78 - writeJSON(w, resp) 79 - return 80 - } else { 81 - l.Error("opening repo", "error", err.Error()) 82 - notFound(w) 83 - return 84 - } 85 - } 86 - 87 - var ( 88 - commits []*object.Commit 89 - total int 90 - branches []types.Branch 91 - files []types.NiceTree 92 - tags []object.Tag 93 - ) 94 - 95 - var wg sync.WaitGroup 96 - errorsCh := make(chan error, 5) 97 - 98 - wg.Add(1) 99 - go func() { 100 - defer wg.Done() 101 - cs, err := gr.Commits(0, 60) 102 - if err != nil { 103 - errorsCh <- fmt.Errorf("commits: %w", err) 104 - return 105 - } 106 - commits = cs 107 - }() 108 - 109 - wg.Add(1) 110 - go func() { 111 - defer wg.Done() 112 - t, err := gr.TotalCommits() 113 - if err != nil { 114 - errorsCh <- fmt.Errorf("calculating total: %w", err) 115 - return 116 - } 117 - total = t 118 - }() 119 - 120 - wg.Add(1) 121 - go func() { 122 - defer wg.Done() 123 - bs, err := gr.Branches() 124 - if err != nil { 125 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 126 - return 127 - } 128 - branches = bs 129 - }() 130 - 131 - wg.Add(1) 132 - go func() { 133 - defer wg.Done() 134 - ts, err := gr.Tags() 135 - if err != nil { 136 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 137 - return 138 - } 139 - tags = ts 140 - }() 141 - 142 - wg.Add(1) 143 - go func() { 144 - defer wg.Done() 145 - fs, err := gr.FileTree(r.Context(), "") 146 - if err != nil { 147 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 148 - return 149 - } 150 - files = fs 151 - }() 152 - 153 - wg.Wait() 154 - close(errorsCh) 155 - 156 - // show any errors 157 - for err := range errorsCh { 158 - l.Error("loading repo", "error", err.Error()) 159 - writeError(w, err.Error(), http.StatusInternalServerError) 160 - return 161 - } 162 - 163 - rtags := []*types.TagReference{} 164 - for _, tag := range tags { 165 - var target *object.Tag 166 - if tag.Target != plumbing.ZeroHash { 167 - target = &tag 168 - } 169 - tr := types.TagReference{ 170 - Tag: target, 171 - } 172 - 173 - tr.Reference = types.Reference{ 174 - Name: tag.Name, 175 - Hash: tag.Hash.String(), 176 - } 177 - 178 - if tag.Message != "" { 179 - tr.Message = tag.Message 180 - } 181 - 182 - rtags = append(rtags, &tr) 183 - } 184 - 185 - var readmeContent string 186 - var readmeFile string 187 - for _, readme := range h.c.Repo.Readme { 188 - content, _ := gr.FileContent(readme) 189 - if len(content) > 0 { 190 - readmeContent = string(content) 191 - readmeFile = readme 192 - } 193 - } 194 - 195 - if ref == "" { 196 - mainBranch, err := gr.FindMainBranch() 197 - if err != nil { 198 - writeError(w, err.Error(), http.StatusInternalServerError) 199 - l.Error("finding main branch", "error", err.Error()) 200 - return 201 - } 202 - ref = mainBranch 203 - } 204 - 205 - resp := types.RepoIndexResponse{ 206 - IsEmpty: false, 207 - Ref: ref, 208 - Commits: commits, 209 - Description: getDescription(path), 210 - Readme: readmeContent, 211 - ReadmeFileName: readmeFile, 212 - Files: files, 213 - Branches: branches, 214 - Tags: rtags, 215 - TotalCommits: total, 216 - } 217 - 218 - writeJSON(w, resp) 219 - } 220 - 221 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 222 - treePath := chi.URLParam(r, "*") 223 - ref := chi.URLParam(r, "ref") 224 - ref, _ = url.PathUnescape(ref) 225 - 226 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 227 - 228 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 229 - gr, err := git.Open(path, ref) 230 - if err != nil { 231 - notFound(w) 232 - return 233 - } 234 - 235 - files, err := gr.FileTree(r.Context(), treePath) 236 - if err != nil { 237 - writeError(w, err.Error(), http.StatusInternalServerError) 238 - l.Error("file tree", "error", err.Error()) 239 - return 240 - } 241 - 242 - resp := types.RepoTreeResponse{ 243 - Ref: ref, 244 - Parent: treePath, 245 - Description: getDescription(path), 246 - DotDot: filepath.Dir(treePath), 247 - Files: files, 248 - } 249 - 250 - writeJSON(w, resp) 251 - } 252 - 253 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 254 - treePath := chi.URLParam(r, "*") 255 - ref := chi.URLParam(r, "ref") 256 - ref, _ = url.PathUnescape(ref) 257 - 258 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 259 - 260 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 261 - gr, err := git.Open(path, ref) 262 - if err != nil { 263 - notFound(w) 264 - return 265 - } 266 - 267 - contents, err := gr.RawContent(treePath) 268 - if err != nil { 269 - writeError(w, err.Error(), http.StatusBadRequest) 270 - l.Error("file content", "error", err.Error()) 271 - return 272 - } 273 - 274 - mimeType := http.DetectContentType(contents) 275 - 276 - // exception for svg 277 - if filepath.Ext(treePath) == ".svg" { 278 - mimeType = "image/svg+xml" 279 - } 280 - 281 - contentHash := sha256.Sum256(contents) 282 - eTag := fmt.Sprintf("\"%x\"", contentHash) 283 - 284 - // allow image, video, and text/plain files to be served directly 285 - switch { 286 - case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 287 - if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 288 - w.WriteHeader(http.StatusNotModified) 289 - return 290 - } 291 - w.Header().Set("ETag", eTag) 292 - 293 - case strings.HasPrefix(mimeType, "text/plain"): 294 - w.Header().Set("Cache-Control", "public, no-cache") 295 - 296 - default: 297 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 298 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 299 - return 300 - } 301 - 302 - w.Header().Set("Content-Type", mimeType) 303 - w.Write(contents) 304 - } 305 - 306 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 307 - treePath := chi.URLParam(r, "*") 308 - ref := chi.URLParam(r, "ref") 309 - ref, _ = url.PathUnescape(ref) 310 - 311 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 312 - 313 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 314 - gr, err := git.Open(path, ref) 315 - if err != nil { 316 - notFound(w) 317 - return 318 - } 319 - 320 - var isBinaryFile bool = false 321 - contents, err := gr.FileContent(treePath) 322 - if errors.Is(err, git.ErrBinaryFile) { 323 - isBinaryFile = true 324 - } else if errors.Is(err, object.ErrFileNotFound) { 325 - notFound(w) 326 - return 327 - } else if err != nil { 328 - writeError(w, err.Error(), http.StatusInternalServerError) 329 - return 330 - } 331 - 332 - bytes := []byte(contents) 333 - // safe := string(sanitize(bytes)) 334 - sizeHint := len(bytes) 335 - 336 - resp := types.RepoBlobResponse{ 337 - Ref: ref, 338 - Contents: string(bytes), 339 - Path: treePath, 340 - IsBinary: isBinaryFile, 341 - SizeHint: uint64(sizeHint), 342 - } 343 - 344 - h.showFile(resp, w, l) 345 - } 346 - 347 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 348 - name := chi.URLParam(r, "name") 349 - file := chi.URLParam(r, "file") 350 - 351 - l := h.l.With("handler", "Archive", "name", name, "file", file) 352 - 353 - // TODO: extend this to add more files compression (e.g.: xz) 354 - if !strings.HasSuffix(file, ".tar.gz") { 355 - notFound(w) 356 - return 357 - } 358 - 359 - ref := strings.TrimSuffix(file, ".tar.gz") 360 - 361 - unescapedRef, err := url.PathUnescape(ref) 362 - if err != nil { 363 - notFound(w) 364 - return 365 - } 366 - 367 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 368 - 369 - // This allows the browser to use a proper name for the file when 370 - // downloading 371 - filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 372 - setContentDisposition(w, filename) 373 - setGZipMIME(w) 374 - 375 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 376 - gr, err := git.Open(path, unescapedRef) 377 - if err != nil { 378 - notFound(w) 379 - return 380 - } 381 - 382 - gw := gzip.NewWriter(w) 383 - defer gw.Close() 384 - 385 - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 386 - err = gr.WriteTar(gw, prefix) 387 - if err != nil { 388 - // once we start writing to the body we can't report error anymore 389 - // so we are only left with printing the error. 390 - l.Error("writing tar file", "error", err.Error()) 391 - return 392 - } 393 - 394 - err = gw.Flush() 395 - if err != nil { 396 - // once we start writing to the body we can't report error anymore 397 - // so we are only left with printing the error. 398 - l.Error("flushing?", "error", err.Error()) 399 - return 400 - } 401 - } 402 - 403 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 404 - ref := chi.URLParam(r, "ref") 405 - ref, _ = url.PathUnescape(ref) 406 - 407 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 408 - 409 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 410 - 411 - gr, err := git.Open(path, ref) 412 - if err != nil { 413 - notFound(w) 414 - return 415 - } 416 - 417 - // Get page parameters 418 - page := 1 419 - pageSize := 30 420 - 421 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 422 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 423 - page = p 424 - } 425 - } 426 - 427 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 428 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 429 - pageSize = ps 430 - } 431 - } 432 - 433 - // convert to offset/limit 434 - offset := (page - 1) * pageSize 435 - limit := pageSize 436 - 437 - commits, err := gr.Commits(offset, limit) 438 - if err != nil { 439 - writeError(w, err.Error(), http.StatusInternalServerError) 440 - l.Error("fetching commits", "error", err.Error()) 441 - return 442 - } 443 - 444 - total := len(commits) 445 - 446 - resp := types.RepoLogResponse{ 447 - Commits: commits, 448 - Ref: ref, 449 - Description: getDescription(path), 450 - Log: true, 451 - Total: total, 452 - Page: page, 453 - PerPage: pageSize, 454 - } 455 - 456 - writeJSON(w, resp) 457 - } 458 - 459 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 460 - ref := chi.URLParam(r, "ref") 461 - ref, _ = url.PathUnescape(ref) 462 - 463 - l := h.l.With("handler", "Diff", "ref", ref) 464 - 465 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 466 - gr, err := git.Open(path, ref) 467 - if err != nil { 468 - notFound(w) 469 - return 470 - } 471 - 472 - diff, err := gr.Diff() 473 - if err != nil { 474 - writeError(w, err.Error(), http.StatusInternalServerError) 475 - l.Error("getting diff", "error", err.Error()) 476 - return 477 - } 478 - 479 - resp := types.RepoCommitResponse{ 480 - Ref: ref, 481 - Diff: diff, 482 - } 483 - 484 - writeJSON(w, resp) 485 - } 486 - 487 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 488 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 489 - l := h.l.With("handler", "Refs") 490 - 491 - gr, err := git.Open(path, "") 492 - if err != nil { 493 - notFound(w) 494 - return 495 - } 496 - 497 - tags, err := gr.Tags() 498 - if err != nil { 499 - // Non-fatal, we *should* have at least one branch to show. 500 - l.Warn("getting tags", "error", err.Error()) 501 - } 502 - 503 - rtags := []*types.TagReference{} 504 - for _, tag := range tags { 505 - var target *object.Tag 506 - if tag.Target != plumbing.ZeroHash { 507 - target = &tag 508 - } 509 - tr := types.TagReference{ 510 - Tag: target, 511 - } 512 - 513 - tr.Reference = types.Reference{ 514 - Name: tag.Name, 515 - Hash: tag.Hash.String(), 516 - } 517 - 518 - if tag.Message != "" { 519 - tr.Message = tag.Message 520 - } 521 - 522 - rtags = append(rtags, &tr) 523 - } 524 - 525 - resp := types.RepoTagsResponse{ 526 - Tags: rtags, 527 - } 528 - 529 - writeJSON(w, resp) 530 - } 531 - 532 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 533 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 534 - 535 - gr, err := git.PlainOpen(path) 536 - if err != nil { 537 - notFound(w) 538 - return 539 - } 540 - 541 - branches, _ := gr.Branches() 542 - 543 - resp := types.RepoBranchesResponse{ 544 - Branches: branches, 545 - } 546 - 547 - writeJSON(w, resp) 548 - } 549 - 550 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 551 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 552 - branchName := chi.URLParam(r, "branch") 553 - branchName, _ = url.PathUnescape(branchName) 554 - 555 - l := h.l.With("handler", "Branch") 556 - 557 - gr, err := git.PlainOpen(path) 558 - if err != nil { 559 - notFound(w) 560 - return 561 - } 562 - 563 - ref, err := gr.Branch(branchName) 564 - if err != nil { 565 - l.Error("getting branch", "error", err.Error()) 566 - writeError(w, err.Error(), http.StatusInternalServerError) 567 - return 568 - } 569 - 570 - commit, err := gr.Commit(ref.Hash()) 571 - if err != nil { 572 - l.Error("getting commit object", "error", err.Error()) 573 - writeError(w, err.Error(), http.StatusInternalServerError) 574 - return 575 - } 576 - 577 - defaultBranch, err := gr.FindMainBranch() 578 - isDefault := false 579 - if err != nil { 580 - l.Error("getting default branch", "error", err.Error()) 581 - // do not quit though 582 - } else if defaultBranch == branchName { 583 - isDefault = true 584 - } 585 - 586 - resp := types.RepoBranchResponse{ 587 - Branch: types.Branch{ 588 - Reference: types.Reference{ 589 - Name: ref.Name().Short(), 590 - Hash: ref.Hash().String(), 591 - }, 592 - Commit: commit, 593 - IsDefault: isDefault, 594 - }, 595 - } 596 - 597 - writeJSON(w, resp) 598 - } 599 - 600 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 - l := h.l.With("handler", "Keys") 602 - 603 - switch r.Method { 604 - case http.MethodGet: 605 - keys, err := h.db.GetAllPublicKeys() 606 - if err != nil { 607 - writeError(w, err.Error(), http.StatusInternalServerError) 608 - l.Error("getting public keys", "error", err.Error()) 609 - return 610 - } 611 - 612 - data := make([]map[string]any, 0) 613 - for _, key := range keys { 614 - j := key.JSON() 615 - data = append(data, j) 616 - } 617 - writeJSON(w, data) 618 - return 619 - 620 - case http.MethodPut: 621 - pk := db.PublicKey{} 622 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 - writeError(w, "invalid request body", http.StatusBadRequest) 624 - return 625 - } 626 - 627 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 - if err != nil { 629 - writeError(w, "invalid pubkey", http.StatusBadRequest) 630 - } 631 - 632 - if err := h.db.AddPublicKey(pk); err != nil { 633 - writeError(w, err.Error(), http.StatusInternalServerError) 634 - l.Error("adding public key", "error", err.Error()) 635 - return 636 - } 637 - 638 - w.WriteHeader(http.StatusNoContent) 639 - return 640 - } 641 - } 642 - 643 - // func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 644 - // l := h.l.With("handler", "RepoForkSync") 645 - // 646 - // data := struct { 647 - // Did string `json:"did"` 648 - // Source string `json:"source"` 649 - // Name string `json:"name,omitempty"` 650 - // HiddenRef string `json:"hiddenref"` 651 - // }{} 652 - // 653 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 654 - // writeError(w, "invalid request body", http.StatusBadRequest) 655 - // return 656 - // } 657 - // 658 - // did := data.Did 659 - // source := data.Source 660 - // 661 - // if did == "" || source == "" { 662 - // l.Error("invalid request body, empty did or name") 663 - // w.WriteHeader(http.StatusBadRequest) 664 - // return 665 - // } 666 - // 667 - // var name string 668 - // if data.Name != "" { 669 - // name = data.Name 670 - // } else { 671 - // name = filepath.Base(source) 672 - // } 673 - // 674 - // branch := chi.URLParam(r, "branch") 675 - // branch, _ = url.PathUnescape(branch) 676 - // 677 - // relativeRepoPath := filepath.Join(did, name) 678 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 679 - // 680 - // gr, err := git.PlainOpen(repoPath) 681 - // if err != nil { 682 - // log.Println(err) 683 - // notFound(w) 684 - // return 685 - // } 686 - // 687 - // forkCommit, err := gr.ResolveRevision(branch) 688 - // if err != nil { 689 - // l.Error("error resolving ref revision", "msg", err.Error()) 690 - // writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 691 - // return 692 - // } 693 - // 694 - // sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 695 - // if err != nil { 696 - // l.Error("error resolving hidden ref revision", "msg", err.Error()) 697 - // writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 698 - // return 699 - // } 700 - // 701 - // status := types.UpToDate 702 - // if forkCommit.Hash.String() != sourceCommit.Hash.String() { 703 - // isAncestor, err := forkCommit.IsAncestor(sourceCommit) 704 - // if err != nil { 705 - // log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 706 - // return 707 - // } 708 - // 709 - // if isAncestor { 710 - // status = types.FastForwardable 711 - // } else { 712 - // status = types.Conflict 713 - // } 714 - // } 715 - // 716 - // w.Header().Set("Content-Type", "application/json") 717 - // json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 718 - // } 719 - 720 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 721 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 722 - ref := chi.URLParam(r, "ref") 723 - ref, _ = url.PathUnescape(ref) 724 - 725 - l := h.l.With("handler", "RepoLanguages") 726 - 727 - gr, err := git.Open(repoPath, ref) 728 - if err != nil { 729 - l.Error("opening repo", "error", err.Error()) 730 - notFound(w) 731 - return 732 - } 733 - 734 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 735 - defer cancel() 736 - 737 - sizes, err := gr.AnalyzeLanguages(ctx) 738 - if err != nil { 739 - l.Error("failed to analyze languages", "error", err.Error()) 740 - writeError(w, err.Error(), http.StatusNoContent) 741 - return 742 - } 743 - 744 - resp := types.RepoLanguageResponse{Languages: sizes} 745 - 746 - writeJSON(w, resp) 747 - } 748 - 749 - // func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 750 - // l := h.l.With("handler", "RepoForkSync") 751 - // 752 - // data := struct { 753 - // Did string `json:"did"` 754 - // Source string `json:"source"` 755 - // Name string `json:"name,omitempty"` 756 - // }{} 757 - // 758 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 759 - // writeError(w, "invalid request body", http.StatusBadRequest) 760 - // return 761 - // } 762 - // 763 - // did := data.Did 764 - // source := data.Source 765 - // 766 - // if did == "" || source == "" { 767 - // l.Error("invalid request body, empty did or name") 768 - // w.WriteHeader(http.StatusBadRequest) 769 - // return 770 - // } 771 - // 772 - // var name string 773 - // if data.Name != "" { 774 - // name = data.Name 775 - // } else { 776 - // name = filepath.Base(source) 777 - // } 778 - // 779 - // branch := chi.URLParam(r, "branch") 780 - // branch, _ = url.PathUnescape(branch) 781 - // 782 - // relativeRepoPath := filepath.Join(did, name) 783 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 784 - // 785 - // gr, err := git.Open(repoPath, branch) 786 - // if err != nil { 787 - // log.Println(err) 788 - // notFound(w) 789 - // return 790 - // } 791 - // 792 - // err = gr.Sync() 793 - // if err != nil { 794 - // l.Error("error syncing repo fork", "error", err.Error()) 795 - // writeError(w, err.Error(), http.StatusInternalServerError) 796 - // return 797 - // } 798 - // 799 - // w.WriteHeader(http.StatusNoContent) 800 - // } 801 - 802 - // func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 803 - // l := h.l.With("handler", "RepoFork") 804 - // 805 - // data := struct { 806 - // Did string `json:"did"` 807 - // Source string `json:"source"` 808 - // Name string `json:"name,omitempty"` 809 - // }{} 810 - // 811 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 812 - // writeError(w, "invalid request body", http.StatusBadRequest) 813 - // return 814 - // } 815 - // 816 - // did := data.Did 817 - // source := data.Source 818 - // 819 - // if did == "" || source == "" { 820 - // l.Error("invalid request body, empty did or name") 821 - // w.WriteHeader(http.StatusBadRequest) 822 - // return 823 - // } 824 - // 825 - // var name string 826 - // if data.Name != "" { 827 - // name = data.Name 828 - // } else { 829 - // name = filepath.Base(source) 830 - // } 831 - // 832 - // relativeRepoPath := filepath.Join(did, name) 833 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 834 - // 835 - // err := git.Fork(repoPath, source) 836 - // if err != nil { 837 - // l.Error("forking repo", "error", err.Error()) 838 - // writeError(w, err.Error(), http.StatusInternalServerError) 839 - // return 840 - // } 841 - // 842 - // // add perms for this user to access the repo 843 - // err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 844 - // if err != nil { 845 - // l.Error("adding repo permissions", "error", err.Error()) 846 - // writeError(w, err.Error(), http.StatusInternalServerError) 847 - // return 848 - // } 849 - // 850 - // hook.SetupRepo( 851 - // hook.Config( 852 - // hook.WithScanPath(h.c.Repo.ScanPath), 853 - // hook.WithInternalApi(h.c.Server.InternalListenAddr), 854 - // ), 855 - // repoPath, 856 - // ) 857 - // 858 - // w.WriteHeader(http.StatusNoContent) 859 - // } 860 - 861 - // func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 862 - // l := h.l.With("handler", "RemoveRepo") 863 - // 864 - // data := struct { 865 - // Did string `json:"did"` 866 - // Name string `json:"name"` 867 - // }{} 868 - // 869 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 870 - // writeError(w, "invalid request body", http.StatusBadRequest) 871 - // return 872 - // } 873 - // 874 - // did := data.Did 875 - // name := data.Name 876 - // 877 - // if did == "" || name == "" { 878 - // l.Error("invalid request body, empty did or name") 879 - // w.WriteHeader(http.StatusBadRequest) 880 - // return 881 - // } 882 - // 883 - // relativeRepoPath := filepath.Join(did, name) 884 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 885 - // err := os.RemoveAll(repoPath) 886 - // if err != nil { 887 - // l.Error("removing repo", "error", err.Error()) 888 - // writeError(w, err.Error(), http.StatusInternalServerError) 889 - // return 890 - // } 891 - // 892 - // w.WriteHeader(http.StatusNoContent) 893 - // 894 - // } 895 - 896 - // func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 897 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 898 - // 899 - // data := types.MergeRequest{} 900 - // 901 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 902 - // writeError(w, err.Error(), http.StatusBadRequest) 903 - // h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 904 - // return 905 - // } 906 - // 907 - // mo := &git.MergeOptions{ 908 - // AuthorName: data.AuthorName, 909 - // AuthorEmail: data.AuthorEmail, 910 - // CommitBody: data.CommitBody, 911 - // CommitMessage: data.CommitMessage, 912 - // } 913 - // 914 - // patch := data.Patch 915 - // branch := data.Branch 916 - // gr, err := git.Open(path, branch) 917 - // if err != nil { 918 - // notFound(w) 919 - // return 920 - // } 921 - // 922 - // mo.FormatPatch = patchutil.IsFormatPatch(patch) 923 - // 924 - // if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 925 - // var mergeErr *git.ErrMerge 926 - // if errors.As(err, &mergeErr) { 927 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 928 - // for i, conflict := range mergeErr.Conflicts { 929 - // conflicts[i] = types.ConflictInfo{ 930 - // Filename: conflict.Filename, 931 - // Reason: conflict.Reason, 932 - // } 933 - // } 934 - // response := types.MergeCheckResponse{ 935 - // IsConflicted: true, 936 - // Conflicts: conflicts, 937 - // Message: mergeErr.Message, 938 - // } 939 - // writeConflict(w, response) 940 - // h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 941 - // } else { 942 - // writeError(w, err.Error(), http.StatusBadRequest) 943 - // h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 944 - // } 945 - // return 946 - // } 947 - // 948 - // w.WriteHeader(http.StatusOK) 949 - // } 950 - 951 - // func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 952 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 953 - // 954 - // var data struct { 955 - // Patch string `json:"patch"` 956 - // Branch string `json:"branch"` 957 - // } 958 - // 959 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 960 - // writeError(w, err.Error(), http.StatusBadRequest) 961 - // h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 962 - // return 963 - // } 964 - // 965 - // patch := data.Patch 966 - // branch := data.Branch 967 - // gr, err := git.Open(path, branch) 968 - // if err != nil { 969 - // notFound(w) 970 - // return 971 - // } 972 - // 973 - // err = gr.MergeCheck([]byte(patch), branch) 974 - // if err == nil { 975 - // response := types.MergeCheckResponse{ 976 - // IsConflicted: false, 977 - // } 978 - // writeJSON(w, response) 979 - // return 980 - // } 981 - // 982 - // var mergeErr *git.ErrMerge 983 - // if errors.As(err, &mergeErr) { 984 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 985 - // for i, conflict := range mergeErr.Conflicts { 986 - // conflicts[i] = types.ConflictInfo{ 987 - // Filename: conflict.Filename, 988 - // Reason: conflict.Reason, 989 - // } 990 - // } 991 - // response := types.MergeCheckResponse{ 992 - // IsConflicted: true, 993 - // Conflicts: conflicts, 994 - // Message: mergeErr.Message, 995 - // } 996 - // writeConflict(w, response) 997 - // h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 998 - // return 999 - // } 1000 - // writeError(w, err.Error(), http.StatusInternalServerError) 1001 - // h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1002 - // } 1003 - 1004 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1005 - rev1 := chi.URLParam(r, "rev1") 1006 - rev1, _ = url.PathUnescape(rev1) 1007 - 1008 - rev2 := chi.URLParam(r, "rev2") 1009 - rev2, _ = url.PathUnescape(rev2) 1010 - 1011 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1012 - 1013 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1014 - gr, err := git.PlainOpen(path) 1015 - if err != nil { 1016 - notFound(w) 1017 - return 1018 - } 1019 - 1020 - commit1, err := gr.ResolveRevision(rev1) 1021 - if err != nil { 1022 - l.Error("error resolving revision 1", "msg", err.Error()) 1023 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1024 - return 1025 - } 1026 - 1027 - commit2, err := gr.ResolveRevision(rev2) 1028 - if err != nil { 1029 - l.Error("error resolving revision 2", "msg", err.Error()) 1030 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1031 - return 1032 - } 1033 - 1034 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1035 - if err != nil { 1036 - l.Error("error comparing revisions", "msg", err.Error()) 1037 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1038 - return 1039 - } 1040 - 1041 - writeJSON(w, types.RepoFormatPatchResponse{ 1042 - Rev1: commit1.Hash.String(), 1043 - Rev2: commit2.Hash.String(), 1044 - FormatPatch: formatPatch, 1045 - Patch: rawPatch, 1046 - }) 1047 - } 1048 - 1049 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1050 - l := h.l.With("handler", "DefaultBranch") 1051 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1052 - 1053 - gr, err := git.Open(path, "") 1054 - if err != nil { 1055 - notFound(w) 1056 - return 1057 - } 1058 - 1059 - branch, err := gr.FindMainBranch() 1060 - if err != nil { 1061 - writeError(w, err.Error(), http.StatusInternalServerError) 1062 - l.Error("getting default branch", "error", err.Error()) 1063 - return 1064 - } 1065 - 1066 - writeJSON(w, types.RepoDefaultBranchResponse{ 1067 - Branch: branch, 1068 - }) 1069 - }
-4
knotserver/http_util.go
··· 16 16 w.WriteHeader(status) 17 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
+21 -16
knotserver/ingester.go
··· 15 15 "github.com/bluesky-social/indigo/xrpc" 16 16 "github.com/bluesky-social/jetstream/pkg/models" 17 17 securejoin "github.com/cyphar/filepath-securejoin" 18 - "tangled.sh/tangled.sh/core/api/tangled" 19 - "tangled.sh/tangled.sh/core/idresolver" 20 - "tangled.sh/tangled.sh/core/knotserver/db" 21 - "tangled.sh/tangled.sh/core/knotserver/git" 22 - "tangled.sh/tangled.sh/core/log" 23 - "tangled.sh/tangled.sh/core/rbac" 24 - "tangled.sh/tangled.sh/core/workflow" 18 + "tangled.org/core/api/tangled" 19 + "tangled.org/core/idresolver" 20 + "tangled.org/core/knotserver/db" 21 + "tangled.org/core/knotserver/git" 22 + "tangled.org/core/log" 23 + "tangled.org/core/rbac" 24 + "tangled.org/core/workflow" 25 25 ) 26 26 27 - func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error { 27 + func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { 28 28 l := log.FromContext(ctx) 29 29 raw := json.RawMessage(event.Commit.Record) 30 30 did := event.Did ··· 46 46 return nil 47 47 } 48 48 49 - func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error { 49 + func (h *Knot) processKnotMember(ctx context.Context, event *models.Event) error { 50 50 l := log.FromContext(ctx) 51 51 raw := json.RawMessage(event.Commit.Record) 52 52 did := event.Did ··· 86 86 return nil 87 87 } 88 88 89 - func (h *Handle) processPull(ctx context.Context, event *models.Event) error { 89 + func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 90 90 raw := json.RawMessage(event.Commit.Record) 91 91 did := event.Did 92 92 ··· 98 98 l := log.FromContext(ctx) 99 99 l = l.With("handler", "processPull") 100 100 l = l.With("did", did) 101 + 102 + if record.Target == nil { 103 + return fmt.Errorf("ignoring pull record: target repo is nil") 104 + } 105 + 101 106 l = l.With("target_repo", record.Target.Repo) 102 107 l = l.With("target_branch", record.Target.Branch) 103 108 ··· 136 141 return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 137 142 } 138 143 139 - didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 144 + didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 140 145 if err != nil { 141 146 return fmt.Errorf("failed to construct relative repo path: %w", err) 142 147 } ··· 146 151 return fmt.Errorf("failed to construct absolute repo path: %w", err) 147 152 } 148 153 149 - gr, err := git.Open(repoPath, record.Source.Branch) 154 + gr, err := git.Open(repoPath, record.Source.Sha) 150 155 if err != nil { 151 156 return fmt.Errorf("failed to open git repository: %w", err) 152 157 } ··· 186 191 Kind: string(workflow.TriggerKindPullRequest), 187 192 PullRequest: &trigger, 188 193 Repo: &tangled.Pipeline_TriggerRepo{ 189 - Did: repo.Owner, 194 + Did: ident.DID.String(), 190 195 Knot: repo.Knot, 191 196 Repo: repo.Name, 192 197 }, ··· 214 219 } 215 220 216 221 // duplicated from add collaborator 217 - func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error { 222 + func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 218 223 raw := json.RawMessage(event.Commit.Record) 219 224 did := event.Did 220 225 ··· 275 280 return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 276 281 } 277 282 278 - func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 283 + func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error { 279 284 l := log.FromContext(ctx) 280 285 281 286 keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) ··· 318 323 return nil 319 324 } 320 325 321 - func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 326 + func (h *Knot) processMessages(ctx context.Context, event *models.Event) error { 322 327 if event.Kind != models.EventKindCommit { 323 328 return nil 324 329 }
+54 -8
knotserver/internal.go
··· 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 14 "github.com/go-chi/chi/v5" 15 15 "github.com/go-chi/chi/v5/middleware" 16 - "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/hook" 18 - "tangled.sh/tangled.sh/core/knotserver/config" 19 - "tangled.sh/tangled.sh/core/knotserver/db" 20 - "tangled.sh/tangled.sh/core/knotserver/git" 21 - "tangled.sh/tangled.sh/core/notifier" 22 - "tangled.sh/tangled.sh/core/rbac" 23 - "tangled.sh/tangled.sh/core/workflow" 16 + "github.com/go-git/go-git/v5/plumbing" 17 + "tangled.org/core/api/tangled" 18 + "tangled.org/core/hook" 19 + "tangled.org/core/idresolver" 20 + "tangled.org/core/knotserver/config" 21 + "tangled.org/core/knotserver/db" 22 + "tangled.org/core/knotserver/git" 23 + "tangled.org/core/notifier" 24 + "tangled.org/core/rbac" 25 + "tangled.org/core/workflow" 24 26 ) 25 27 26 28 type InternalHandle struct { ··· 118 120 // non-fatal 119 121 } 120 122 123 + if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 124 + msg, err := h.replyCompare(line, gitUserDid, gitRelativeDir, repoName, r.Context()) 125 + if err != nil { 126 + l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 127 + // non-fatal 128 + } else { 129 + for msgLine := range msg { 130 + resp.Messages = append(resp.Messages, msg[msgLine]) 131 + } 132 + } 133 + } 134 + 121 135 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 122 136 if err != nil { 123 137 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 126 140 } 127 141 128 142 writeJSON(w, resp) 143 + } 144 + 145 + func (h *InternalHandle) replyCompare(line git.PostReceiveLine, gitUserDid string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 146 + l := h.l.With("handler", "replyCompare") 147 + userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, gitUserDid) 148 + user := gitUserDid 149 + if err != nil { 150 + l.Error("Failed to fetch user identity", "err", err) 151 + // non-fatal 152 + } else { 153 + user = userIdent.Handle.String() 154 + } 155 + gr, err := git.PlainOpen(gitRelativeDir) 156 + if err != nil { 157 + l.Error("Failed to open git repository", "err", err) 158 + return []string{}, err 159 + } 160 + defaultBranch, err := gr.FindMainBranch() 161 + if err != nil { 162 + l.Error("Failed to fetch default branch", "err", err) 163 + return []string{}, err 164 + } 165 + if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() { 166 + return []string{}, nil 167 + } 168 + ZWS := "\u200B" 169 + var msg []string 170 + msg = append(msg, ZWS) 171 + msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 172 + msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 173 + msg = append(msg, ZWS) 174 + return msg, nil 129 175 } 130 176 131 177 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
+152
knotserver/router.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "tangled.org/core/idresolver" 11 + "tangled.org/core/jetstream" 12 + "tangled.org/core/knotserver/config" 13 + "tangled.org/core/knotserver/db" 14 + "tangled.org/core/knotserver/xrpc" 15 + tlog "tangled.org/core/log" 16 + "tangled.org/core/notifier" 17 + "tangled.org/core/rbac" 18 + "tangled.org/core/xrpc/serviceauth" 19 + ) 20 + 21 + type Knot struct { 22 + c *config.Config 23 + db *db.DB 24 + jc *jetstream.JetstreamClient 25 + e *rbac.Enforcer 26 + l *slog.Logger 27 + n *notifier.Notifier 28 + resolver *idresolver.Resolver 29 + } 30 + 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 32 + r := chi.NewRouter() 33 + 34 + h := Knot{ 35 + c: c, 36 + db: db, 37 + e: e, 38 + l: l, 39 + jc: jc, 40 + n: n, 41 + resolver: idresolver.DefaultResolver(), 42 + } 43 + 44 + err := e.AddKnot(rbac.ThisServer) 45 + if err != nil { 46 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 47 + } 48 + 49 + // configure owner 50 + if err = h.configureOwner(); err != nil { 51 + return nil, err 52 + } 53 + h.l.Info("owner set", "did", h.c.Server.Owner) 54 + h.jc.AddDid(h.c.Server.Owner) 55 + 56 + // configure known-dids in jetstream consumer 57 + dids, err := h.db.GetAllDids() 58 + if err != nil { 59 + return nil, fmt.Errorf("failed to get all dids: %w", err) 60 + } 61 + for _, d := range dids { 62 + jc.AddDid(d) 63 + } 64 + 65 + err = h.jc.StartJetstream(ctx, h.processMessages) 66 + if err != nil { 67 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 + } 69 + 70 + r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 + }) 73 + 74 + r.Route("/{did}", func(r chi.Router) { 75 + r.Route("/{name}", func(r chi.Router) { 76 + // routes for git operations 77 + r.Get("/info/refs", h.InfoRefs) 78 + r.Post("/git-upload-pack", h.UploadPack) 79 + r.Post("/git-receive-pack", h.ReceivePack) 80 + }) 81 + }) 82 + 83 + // xrpc apis 84 + r.Mount("/xrpc", h.XrpcRouter()) 85 + 86 + // Socket that streams git oplogs 87 + r.Get("/events", h.Events) 88 + 89 + return r, nil 90 + } 91 + 92 + func (h *Knot) XrpcRouter() http.Handler { 93 + logger := tlog.New("knots") 94 + 95 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 96 + 97 + xrpc := &xrpc.Xrpc{ 98 + Config: h.c, 99 + Db: h.db, 100 + Ingester: h.jc, 101 + Enforcer: h.e, 102 + Logger: logger, 103 + Notifier: h.n, 104 + Resolver: h.resolver, 105 + ServiceAuth: serviceAuth, 106 + } 107 + return xrpc.Router() 108 + } 109 + 110 + func (h *Knot) configureOwner() error { 111 + cfgOwner := h.c.Server.Owner 112 + 113 + rbacDomain := "thisserver" 114 + 115 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 116 + if err != nil { 117 + return err 118 + } 119 + 120 + switch len(existing) { 121 + case 0: 122 + // no owner configured, continue 123 + case 1: 124 + // find existing owner 125 + existingOwner := existing[0] 126 + 127 + // no ownership change, this is okay 128 + if existingOwner == h.c.Server.Owner { 129 + break 130 + } 131 + 132 + // remove existing owner 133 + if err = h.db.RemoveDid(existingOwner); err != nil { 134 + return err 135 + } 136 + if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil { 137 + return err 138 + } 139 + 140 + default: 141 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 142 + } 143 + 144 + if err = h.db.AddDid(cfgOwner); err != nil { 145 + return fmt.Errorf("failed to add owner to DB: %w", err) 146 + } 147 + if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil { 148 + return fmt.Errorf("failed to add owner to RBAC: %w", err) 149 + } 150 + 151 + return nil 152 + }
-217
knotserver/routes.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "log/slog" 7 - "net/http" 8 - "runtime/debug" 9 - 10 - "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 14 - "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 - tlog "tangled.sh/tangled.sh/core/log" 17 - "tangled.sh/tangled.sh/core/notifier" 18 - "tangled.sh/tangled.sh/core/rbac" 19 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 - ) 21 - 22 - type Handle struct { 23 - c *config.Config 24 - db *db.DB 25 - jc *jetstream.JetstreamClient 26 - e *rbac.Enforcer 27 - l *slog.Logger 28 - n *notifier.Notifier 29 - resolver *idresolver.Resolver 30 - } 31 - 32 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 33 - r := chi.NewRouter() 34 - 35 - h := Handle{ 36 - c: c, 37 - db: db, 38 - e: e, 39 - l: l, 40 - jc: jc, 41 - n: n, 42 - resolver: idresolver.DefaultResolver(), 43 - } 44 - 45 - err := e.AddKnot(rbac.ThisServer) 46 - if err != nil { 47 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 48 - } 49 - 50 - // configure owner 51 - if err = h.configureOwner(); err != nil { 52 - return nil, err 53 - } 54 - h.l.Info("owner set", "did", h.c.Server.Owner) 55 - h.jc.AddDid(h.c.Server.Owner) 56 - 57 - // configure known-dids in jetstream consumer 58 - dids, err := h.db.GetAllDids() 59 - if err != nil { 60 - return nil, fmt.Errorf("failed to get all dids: %w", err) 61 - } 62 - for _, d := range dids { 63 - jc.AddDid(d) 64 - } 65 - 66 - err = h.jc.StartJetstream(ctx, h.processMessages) 67 - if err != nil { 68 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 69 - } 70 - 71 - r.Get("/", h.Index) 72 - r.Get("/capabilities", h.Capabilities) 73 - r.Get("/version", h.Version) 74 - r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { 75 - w.Write([]byte(h.c.Server.Owner)) 76 - }) 77 - r.Route("/{did}", func(r chi.Router) { 78 - // Repo routes 79 - r.Route("/{name}", func(r chi.Router) { 80 - 81 - r.Route("/languages", func(r chi.Router) { 82 - r.Get("/", h.RepoLanguages) 83 - r.Get("/{ref}", h.RepoLanguages) 84 - }) 85 - 86 - r.Get("/", h.RepoIndex) 87 - r.Get("/info/refs", h.InfoRefs) 88 - r.Post("/git-upload-pack", h.UploadPack) 89 - r.Post("/git-receive-pack", h.ReceivePack) 90 - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 91 - 92 - r.Route("/tree/{ref}", func(r chi.Router) { 93 - r.Get("/", h.RepoIndex) 94 - r.Get("/*", h.RepoTree) 95 - }) 96 - 97 - r.Route("/blob/{ref}", func(r chi.Router) { 98 - r.Get("/*", h.Blob) 99 - }) 100 - 101 - r.Route("/raw/{ref}", func(r chi.Router) { 102 - r.Get("/*", h.BlobRaw) 103 - }) 104 - 105 - r.Get("/log/{ref}", h.Log) 106 - r.Get("/archive/{file}", h.Archive) 107 - r.Get("/commit/{ref}", h.Diff) 108 - r.Get("/tags", h.Tags) 109 - r.Route("/branches", func(r chi.Router) { 110 - r.Get("/", h.Branches) 111 - r.Get("/{branch}", h.Branch) 112 - r.Get("/default", h.DefaultBranch) 113 - }) 114 - }) 115 - }) 116 - 117 - // xrpc apis 118 - r.Mount("/xrpc", h.XrpcRouter()) 119 - 120 - // Socket that streams git oplogs 121 - r.Get("/events", h.Events) 122 - 123 - // All public keys on the knot. 124 - r.Get("/keys", h.Keys) 125 - 126 - return r, nil 127 - } 128 - 129 - func (h *Handle) XrpcRouter() http.Handler { 130 - logger := tlog.New("knots") 131 - 132 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 133 - 134 - xrpc := &xrpc.Xrpc{ 135 - Config: h.c, 136 - Db: h.db, 137 - Ingester: h.jc, 138 - Enforcer: h.e, 139 - Logger: logger, 140 - Notifier: h.n, 141 - Resolver: h.resolver, 142 - ServiceAuth: serviceAuth, 143 - } 144 - return xrpc.Router() 145 - } 146 - 147 - // version is set during build time. 148 - var version string 149 - 150 - func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 151 - if version == "" { 152 - info, ok := debug.ReadBuildInfo() 153 - if !ok { 154 - http.Error(w, "failed to read build info", http.StatusInternalServerError) 155 - return 156 - } 157 - 158 - var modVer string 159 - for _, mod := range info.Deps { 160 - if mod.Path == "tangled.sh/tangled.sh/knotserver" { 161 - version = mod.Version 162 - break 163 - } 164 - } 165 - 166 - if modVer == "" { 167 - version = "unknown" 168 - } 169 - } 170 - 171 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 172 - fmt.Fprintf(w, "knotserver/%s", version) 173 - } 174 - 175 - func (h *Handle) configureOwner() error { 176 - cfgOwner := h.c.Server.Owner 177 - 178 - rbacDomain := "thisserver" 179 - 180 - existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 181 - if err != nil { 182 - return err 183 - } 184 - 185 - switch len(existing) { 186 - case 0: 187 - // no owner configured, continue 188 - case 1: 189 - // find existing owner 190 - existingOwner := existing[0] 191 - 192 - // no ownership change, this is okay 193 - if existingOwner == h.c.Server.Owner { 194 - break 195 - } 196 - 197 - // remove existing owner 198 - if err = h.db.RemoveDid(existingOwner); err != nil { 199 - return err 200 - } 201 - if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil { 202 - return err 203 - } 204 - 205 - default: 206 - return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 207 - } 208 - 209 - if err = h.db.AddDid(cfgOwner); err != nil { 210 - return fmt.Errorf("failed to add owner to DB: %w", err) 211 - } 212 - if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil { 213 - return fmt.Errorf("failed to add owner to RBAC: %w", err) 214 - } 215 - 216 - return nil 217 - }
+24 -21
knotserver/server.go
··· 6 6 "net/http" 7 7 8 8 "github.com/urfave/cli/v3" 9 - "tangled.sh/tangled.sh/core/api/tangled" 10 - "tangled.sh/tangled.sh/core/hook" 11 - "tangled.sh/tangled.sh/core/jetstream" 12 - "tangled.sh/tangled.sh/core/knotserver/config" 13 - "tangled.sh/tangled.sh/core/knotserver/db" 14 - "tangled.sh/tangled.sh/core/log" 15 - "tangled.sh/tangled.sh/core/notifier" 16 - "tangled.sh/tangled.sh/core/rbac" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/hook" 11 + "tangled.org/core/jetstream" 12 + "tangled.org/core/knotserver/config" 13 + "tangled.org/core/knotserver/db" 14 + "tangled.org/core/log" 15 + "tangled.org/core/notifier" 16 + "tangled.org/core/rbac" 17 17 ) 18 18 19 19 func Command() *cli.Command { ··· 22 22 Usage: "run a knot server", 23 23 Action: Run, 24 24 Description: ` 25 - Environment variables: 26 - KNOT_SERVER_SECRET (required) 27 - KNOT_SERVER_HOSTNAME (required) 28 - KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 29 - KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 30 - KNOT_SERVER_DB_PATH (default: knotserver.db) 31 - KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 32 - KNOT_SERVER_DEV (default: false) 33 - KNOT_REPO_SCAN_PATH (default: /home/git) 34 - KNOT_REPO_README (comma-separated list) 35 - KNOT_REPO_MAIN_BRANCH (default: main) 36 - APPVIEW_ENDPOINT (default: https://tangled.sh) 37 - `, 25 + Environment variables: 26 + KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 27 + KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 28 + KNOT_SERVER_DB_PATH (default: knotserver.db) 29 + KNOT_SERVER_HOSTNAME (required) 30 + KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 31 + KNOT_SERVER_OWNER (required) 32 + KNOT_SERVER_LOG_DIDS (default: true) 33 + KNOT_SERVER_DEV (default: false) 34 + KNOT_REPO_SCAN_PATH (default: /home/git) 35 + KNOT_REPO_README (comma-separated list) 36 + KNOT_REPO_MAIN_BRANCH (default: main) 37 + KNOT_GIT_USER_NAME (default: Tangled) 38 + KNOT_GIT_USER_EMAIL (default: noreply@tangled.sh) 39 + APPVIEW_ENDPOINT (default: https://tangled.sh) 40 + `, 38 41 } 39 42 } 40 43
-36
knotserver/util.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 - "net/http" 5 - "os" 6 - "path/filepath" 7 - 8 4 "github.com/bluesky-social/indigo/atproto/syntax" 9 - securejoin "github.com/cyphar/filepath-securejoin" 10 - "github.com/go-chi/chi/v5" 11 5 ) 12 - 13 - func didPath(r *http.Request) string { 14 - did := chi.URLParam(r, "did") 15 - name := chi.URLParam(r, "name") 16 - path, _ := securejoin.SecureJoin(did, name) 17 - filepath.Clean(path) 18 - return path 19 - } 20 - 21 - func getDescription(path string) (desc string) { 22 - db, err := os.ReadFile(filepath.Join(path, "description")) 23 - if err == nil { 24 - desc = string(db) 25 - } else { 26 - desc = "" 27 - } 28 - return 29 - } 30 - func setContentDisposition(w http.ResponseWriter, name string) { 31 - h := "inline; filename=\"" + name + "\"" 32 - w.Header().Add("Content-Disposition", h) 33 - } 34 - 35 - func setGZipMIME(w http.ResponseWriter) { 36 - setMIME(w, "application/gzip") 37 - } 38 - 39 - func setMIME(w http.ResponseWriter, mime string) { 40 - w.Header().Add("Content-Type", mime) 41 - } 42 6 43 7 var TIDClock = syntax.NewTIDClock(0) 44 8
+5 -5
knotserver/xrpc/create_repo.go
··· 13 13 "github.com/bluesky-social/indigo/xrpc" 14 14 securejoin "github.com/cyphar/filepath-securejoin" 15 15 gogit "github.com/go-git/go-git/v5" 16 - "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/hook" 18 - "tangled.sh/tangled.sh/core/knotserver/git" 19 - "tangled.sh/tangled.sh/core/rbac" 20 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/hook" 18 + "tangled.org/core/knotserver/git" 19 + "tangled.org/core/rbac" 20 + xrpcerr "tangled.org/core/xrpc/errors" 21 21 ) 22 22 23 23 func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
+87
knotserver/xrpc/delete_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 + 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDeleteBranch_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(data.Repo) 40 + if err != nil { 41 + fail(xrpcerr.InvalidRepoError(data.Repo)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 + if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String(), "repo", didPath) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 + gr, err := git.PlainOpen(path) 74 + if err != nil { 75 + fail(xrpcerr.GenericError(err)) 76 + return 77 + } 78 + 79 + err = gr.DeleteBranch(data.Branch) 80 + if err != nil { 81 + l.Error("deleting branch", "error", err.Error(), "branch", data.Branch) 82 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + w.WriteHeader(http.StatusOK) 87 + }
+3 -3
knotserver/xrpc/delete_repo.go
··· 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 "github.com/bluesky-social/indigo/xrpc" 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 - "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/rbac" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/rbac" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+5 -5
knotserver/xrpc/fork_status.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 securejoin "github.com/cyphar/filepath-securejoin" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/knotserver/git" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - "tangled.sh/tangled.sh/core/types" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/knotserver/git" 13 + "tangled.org/core/rbac" 14 + "tangled.org/core/types" 15 + xrpcerr "tangled.org/core/xrpc/errors" 16 16 ) 17 17 18 18 func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/fork_sync.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 securejoin "github.com/cyphar/filepath-securejoin" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/knotserver/git" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/knotserver/git" 13 + "tangled.org/core/rbac" 14 + xrpcerr "tangled.org/core/xrpc/errors" 15 15 ) 16 16 17 17 func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/hidden_ref.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/knotserver/git" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 + xrpcerr "tangled.org/core/xrpc/errors" 16 16 ) 17 17 18 18 func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) {
+49
knotserver/xrpc/list_keys.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { 12 + cursor := r.URL.Query().Get("cursor") 13 + 14 + limit := 100 // default 15 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 16 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 17 + limit = l 18 + } 19 + } 20 + 21 + keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor) 22 + if err != nil { 23 + x.Logger.Error("failed to get public keys", "error", err) 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InternalServerError"), 26 + xrpcerr.WithMessage("failed to retrieve public keys"), 27 + ), http.StatusInternalServerError) 28 + return 29 + } 30 + 31 + publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys)) 32 + for _, key := range keys { 33 + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ 34 + Did: key.Did, 35 + Key: key.Key, 36 + CreatedAt: key.CreatedAt, 37 + }) 38 + } 39 + 40 + response := tangled.KnotListKeys_Output{ 41 + Keys: publicKeys, 42 + } 43 + 44 + if nextCursor != "" { 45 + response.Cursor = &nextCursor 46 + } 47 + 48 + writeJson(w, response) 49 + }
+10 -8
knotserver/xrpc/merge.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 securejoin "github.com/cyphar/filepath-securejoin" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/knotserver/git" 13 - "tangled.sh/tangled.sh/core/patchutil" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - "tangled.sh/tangled.sh/core/types" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/knotserver/git" 13 + "tangled.org/core/patchutil" 14 + "tangled.org/core/rbac" 15 + "tangled.org/core/types" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { ··· 67 67 return 68 68 } 69 69 70 - mo := &git.MergeOptions{} 70 + mo := git.MergeOptions{} 71 71 if data.AuthorName != nil { 72 72 mo.AuthorName = *data.AuthorName 73 73 } ··· 81 81 mo.CommitMessage = *data.CommitMessage 82 82 } 83 83 84 + mo.CommitterName = x.Config.Git.UserName 85 + mo.CommitterEmail = x.Config.Git.UserEmail 84 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 85 87 86 - err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 88 + err = gr.MergeWithOptions(data.Patch, data.Branch, mo) 87 89 if err != nil { 88 90 var mergeErr *git.ErrMerge 89 91 if errors.As(err, &mergeErr) {
+4 -4
knotserver/xrpc/merge_check.go
··· 7 7 "net/http" 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "tangled.sh/tangled.sh/core/api/tangled" 11 - "tangled.sh/tangled.sh/core/knotserver/git" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) { ··· 51 51 return 52 52 } 53 53 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 54 + err = gr.MergeCheck(data.Patch, data.Branch) 55 55 56 56 response := tangled.RepoMergeCheck_Output{ 57 57 Is_conflicted: false,
+22
knotserver/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/api/tangled" 7 + xrpcerr "tangled.org/core/xrpc/errors" 8 + ) 9 + 10 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 11 + owner := x.Config.Server.Owner 12 + if owner == "" { 13 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 14 + return 15 + } 16 + 17 + response := tangled.Owner_Output{ 18 + Owner: owner, 19 + } 20 + 21 + writeJson(w, response) 22 + }
+81
knotserver/xrpc/repo_archive.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "compress/gzip" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/go-git/go-git/v5/plumbing" 10 + 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + ref := r.URL.Query().Get("ref") 24 + // ref can be empty (git.Open handles this) 25 + 26 + format := r.URL.Query().Get("format") 27 + if format == "" { 28 + format = "tar.gz" // default 29 + } 30 + 31 + prefix := r.URL.Query().Get("prefix") 32 + 33 + if format != "tar.gz" { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("InvalidRequest"), 36 + xrpcerr.WithMessage("only tar.gz format is supported"), 37 + ), http.StatusBadRequest) 38 + return 39 + } 40 + 41 + gr, err := git.Open(repoPath, ref) 42 + if err != nil { 43 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 44 + return 45 + } 46 + 47 + repoParts := strings.Split(repo, "/") 48 + repoName := repoParts[len(repoParts)-1] 49 + 50 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 51 + 52 + var archivePrefix string 53 + if prefix != "" { 54 + archivePrefix = prefix 55 + } else { 56 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 57 + } 58 + 59 + filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 60 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 61 + w.Header().Set("Content-Type", "application/gzip") 62 + 63 + gw := gzip.NewWriter(w) 64 + defer gw.Close() 65 + 66 + err = gr.WriteTar(gw, archivePrefix) 67 + if err != nil { 68 + // once we start writing to the body we can't report error anymore 69 + // so we are only left with logging the error 70 + x.Logger.Error("writing tar file", "error", err.Error()) 71 + return 72 + } 73 + 74 + err = gw.Flush() 75 + if err != nil { 76 + // once we start writing to the body we can't report error anymore 77 + // so we are only left with logging the error 78 + x.Logger.Error("flushing", "error", err.Error()) 79 + return 80 + } 81 + }
+143
knotserver/xrpc/repo_blob.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "slices" 10 + "strings" 11 + 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + xrpcerr "tangled.org/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 18 + repo := r.URL.Query().Get("repo") 19 + repoPath, err := x.parseRepoParam(repo) 20 + if err != nil { 21 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + ref := r.URL.Query().Get("ref") 26 + // ref can be empty (git.Open handles this) 27 + 28 + treePath := r.URL.Query().Get("path") 29 + if treePath == "" { 30 + writeError(w, xrpcerr.NewXrpcError( 31 + xrpcerr.WithTag("InvalidRequest"), 32 + xrpcerr.WithMessage("missing path parameter"), 33 + ), http.StatusBadRequest) 34 + return 35 + } 36 + 37 + raw := r.URL.Query().Get("raw") == "true" 38 + 39 + gr, err := git.Open(repoPath, ref) 40 + if err != nil { 41 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 42 + return 43 + } 44 + 45 + contents, err := gr.RawContent(treePath) 46 + if err != nil { 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("FileNotFound"), 50 + xrpcerr.WithMessage("file not found at the specified path"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + mimeType := http.DetectContentType(contents) 56 + 57 + if filepath.Ext(treePath) == ".svg" { 58 + mimeType = "image/svg+xml" 59 + } 60 + 61 + if raw { 62 + contentHash := sha256.Sum256(contents) 63 + eTag := fmt.Sprintf("\"%x\"", contentHash) 64 + 65 + switch { 66 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 67 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 68 + w.WriteHeader(http.StatusNotModified) 69 + return 70 + } 71 + w.Header().Set("ETag", eTag) 72 + w.Header().Set("Content-Type", mimeType) 73 + 74 + case strings.HasPrefix(mimeType, "text/"): 75 + w.Header().Set("Cache-Control", "public, no-cache") 76 + // serve all text content as text/plain 77 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 78 + 79 + case isTextualMimeType(mimeType): 80 + // handle textual application types (json, xml, etc.) as text/plain 81 + w.Header().Set("Cache-Control", "public, no-cache") 82 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 83 + 84 + default: 85 + x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType) 86 + writeError(w, xrpcerr.NewXrpcError( 87 + xrpcerr.WithTag("InvalidRequest"), 88 + xrpcerr.WithMessage("only image, video, and text files can be accessed directly"), 89 + ), http.StatusForbidden) 90 + return 91 + } 92 + w.Write(contents) 93 + return 94 + } 95 + 96 + isTextual := func(mt string) bool { 97 + return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt) 98 + } 99 + 100 + var content string 101 + var encoding string 102 + 103 + isBinary := !isTextual(mimeType) 104 + 105 + if isBinary { 106 + content = base64.StdEncoding.EncodeToString(contents) 107 + encoding = "base64" 108 + } else { 109 + content = string(contents) 110 + encoding = "utf-8" 111 + } 112 + 113 + response := tangled.RepoBlob_Output{ 114 + Ref: ref, 115 + Path: treePath, 116 + Content: content, 117 + Encoding: &encoding, 118 + Size: &[]int64{int64(len(contents))}[0], 119 + IsBinary: &isBinary, 120 + } 121 + 122 + if mimeType != "" { 123 + response.MimeType = &mimeType 124 + } 125 + 126 + writeJson(w, response) 127 + } 128 + 129 + // isTextualMimeType returns true if the MIME type represents textual content 130 + // that should be served as text/plain for security reasons 131 + func isTextualMimeType(mimeType string) bool { 132 + textualTypes := []string{ 133 + "application/json", 134 + "application/xml", 135 + "application/yaml", 136 + "application/x-yaml", 137 + "application/toml", 138 + "application/javascript", 139 + "application/ecmascript", 140 + } 141 + 142 + return slices.Contains(textualTypes, mimeType) 143 + }
+85
knotserver/xrpc/repo_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "net/url" 6 + "time" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/knotserver/git" 10 + xrpcerr "tangled.org/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + name := r.URL.Query().Get("name") 22 + if name == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing name parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + branchName, _ := url.PathUnescape(name) 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 35 + return 36 + } 37 + 38 + ref, err := gr.Branch(branchName) 39 + if err != nil { 40 + x.Logger.Error("getting branch", "error", err.Error()) 41 + writeError(w, xrpcerr.NewXrpcError( 42 + xrpcerr.WithTag("BranchNotFound"), 43 + xrpcerr.WithMessage("branch not found"), 44 + ), http.StatusNotFound) 45 + return 46 + } 47 + 48 + commit, err := gr.Commit(ref.Hash()) 49 + if err != nil { 50 + x.Logger.Error("getting commit object", "error", err.Error()) 51 + writeError(w, xrpcerr.NewXrpcError( 52 + xrpcerr.WithTag("BranchNotFound"), 53 + xrpcerr.WithMessage("failed to get commit object"), 54 + ), http.StatusInternalServerError) 55 + return 56 + } 57 + 58 + defaultBranch, err := gr.FindMainBranch() 59 + isDefault := false 60 + if err != nil { 61 + x.Logger.Error("getting default branch", "error", err.Error()) 62 + } else if defaultBranch == branchName { 63 + isDefault = true 64 + } 65 + 66 + response := tangled.RepoBranch_Output{ 67 + Name: ref.Name().Short(), 68 + Hash: ref.Hash().String(), 69 + ShortHash: &[]string{ref.Hash().String()[:7]}[0], 70 + When: commit.Author.When.Format(time.RFC3339), 71 + IsDefault: &isDefault, 72 + } 73 + 74 + if commit.Message != "" { 75 + response.Message = &commit.Message 76 + } 77 + 78 + response.Author = &tangled.RepoBranch_Signature{ 79 + Name: commit.Author.Name, 80 + Email: commit.Author.Email, 81 + When: commit.Author.When.Format(time.RFC3339), 82 + } 83 + 84 + writeJson(w, response) 85 + }
+56
knotserver/xrpc/repo_branches.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + cursor := r.URL.Query().Get("cursor") 21 + 22 + // limit := 50 // default 23 + // if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 24 + // if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 25 + // limit = l 26 + // } 27 + // } 28 + 29 + limit := 500 30 + 31 + gr, err := git.PlainOpen(repoPath) 32 + if err != nil { 33 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 34 + return 35 + } 36 + 37 + branches, _ := gr.Branches() 38 + 39 + offset := 0 40 + if cursor != "" { 41 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 42 + offset = o 43 + } 44 + } 45 + 46 + end := min(offset+limit, len(branches)) 47 + 48 + paginatedBranches := branches[offset:end] 49 + 50 + // Create response using existing types.RepoBranchesResponse 51 + response := types.RepoBranchesResponse{ 52 + Branches: paginatedBranches, 53 + } 54 + 55 + writeJson(w, response) 56 + }
+82
knotserver/xrpc/repo_compare.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + rev1 := r.URL.Query().Get("rev1") 21 + if rev1 == "" { 22 + writeError(w, xrpcerr.NewXrpcError( 23 + xrpcerr.WithTag("InvalidRequest"), 24 + xrpcerr.WithMessage("missing rev1 parameter"), 25 + ), http.StatusBadRequest) 26 + return 27 + } 28 + 29 + rev2 := r.URL.Query().Get("rev2") 30 + if rev2 == "" { 31 + writeError(w, xrpcerr.NewXrpcError( 32 + xrpcerr.WithTag("InvalidRequest"), 33 + xrpcerr.WithMessage("missing rev2 parameter"), 34 + ), http.StatusBadRequest) 35 + return 36 + } 37 + 38 + gr, err := git.PlainOpen(repoPath) 39 + if err != nil { 40 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 41 + return 42 + } 43 + 44 + commit1, err := gr.ResolveRevision(rev1) 45 + if err != nil { 46 + x.Logger.Error("error resolving revision 1", "msg", err.Error()) 47 + writeError(w, xrpcerr.NewXrpcError( 48 + xrpcerr.WithTag("RevisionNotFound"), 49 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), 50 + ), http.StatusBadRequest) 51 + return 52 + } 53 + 54 + commit2, err := gr.ResolveRevision(rev2) 55 + if err != nil { 56 + x.Logger.Error("error resolving revision 2", "msg", err.Error()) 57 + writeError(w, xrpcerr.NewXrpcError( 58 + xrpcerr.WithTag("RevisionNotFound"), 59 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), 60 + ), http.StatusBadRequest) 61 + return 62 + } 63 + 64 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 65 + if err != nil { 66 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 67 + writeError(w, xrpcerr.NewXrpcError( 68 + xrpcerr.WithTag("CompareError"), 69 + xrpcerr.WithMessage("error comparing revisions"), 70 + ), http.StatusBadRequest) 71 + return 72 + } 73 + 74 + response := types.RepoFormatPatchResponse{ 75 + Rev1: commit1.Hash.String(), 76 + Rev2: commit2.Hash.String(), 77 + FormatPatch: formatPatch, 78 + Patch: rawPatch, 79 + } 80 + 81 + writeJson(w, response) 82 + }
+41
knotserver/xrpc/repo_diff.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/knotserver/git" 7 + "tangled.org/core/types" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { 12 + repo := r.URL.Query().Get("repo") 13 + repoPath, err := x.parseRepoParam(repo) 14 + if err != nil { 15 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 16 + return 17 + } 18 + 19 + ref := r.URL.Query().Get("ref") 20 + // ref can be empty (git.Open handles this) 21 + 22 + gr, err := git.Open(repoPath, ref) 23 + if err != nil { 24 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 25 + return 26 + } 27 + 28 + diff, err := gr.Diff() 29 + if err != nil { 30 + x.Logger.Error("getting diff", "error", err.Error()) 31 + writeError(w, xrpcerr.RefNotFoundError, http.StatusInternalServerError) 32 + return 33 + } 34 + 35 + response := types.RepoCommitResponse{ 36 + Ref: ref, 37 + Diff: diff, 38 + } 39 + 40 + writeJson(w, response) 41 + }
+39
knotserver/xrpc/repo_get_default_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "time" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/knotserver/git" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + gr, err := git.PlainOpen(repoPath) 21 + 22 + branch, err := gr.FindMainBranch() 23 + if err != nil { 24 + x.Logger.Error("getting default branch", "error", err.Error()) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InvalidRequest"), 27 + xrpcerr.WithMessage("failed to get default branch"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + response := tangled.RepoGetDefaultBranch_Output{ 33 + Name: branch, 34 + Hash: "", 35 + When: time.UnixMicro(0).Format(time.RFC3339), 36 + } 37 + 38 + writeJson(w, response) 39 + }
+76
knotserver/xrpc/repo_languages.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "math" 6 + "net/http" 7 + "time" 8 + 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/knotserver/git" 11 + xrpcerr "tangled.org/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + ref := r.URL.Query().Get("ref") 23 + 24 + gr, err := git.Open(repoPath, ref) 25 + if err != nil { 26 + x.Logger.Error("opening repo", "error", err.Error()) 27 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 28 + return 29 + } 30 + 31 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 32 + defer cancel() 33 + 34 + sizes, err := gr.AnalyzeLanguages(ctx) 35 + if err != nil { 36 + x.Logger.Error("failed to analyze languages", "error", err.Error()) 37 + writeError(w, xrpcerr.NewXrpcError( 38 + xrpcerr.WithTag("InvalidRequest"), 39 + xrpcerr.WithMessage("failed to analyze repository languages"), 40 + ), http.StatusNoContent) 41 + return 42 + } 43 + 44 + var apiLanguages []*tangled.RepoLanguages_Language 45 + var totalSize int64 46 + 47 + for _, size := range sizes { 48 + totalSize += size 49 + } 50 + 51 + for name, size := range sizes { 52 + percentagef64 := float64(size) / float64(totalSize) * 100 53 + percentage := math.Round(percentagef64) 54 + 55 + lang := &tangled.RepoLanguages_Language{ 56 + Name: name, 57 + Size: size, 58 + Percentage: int64(percentage), 59 + } 60 + 61 + apiLanguages = append(apiLanguages, lang) 62 + } 63 + 64 + response := tangled.RepoLanguages_Output{ 65 + Ref: ref, 66 + Languages: apiLanguages, 67 + } 68 + 69 + if totalSize > 0 { 70 + response.TotalSize = &totalSize 71 + totalFiles := int64(len(sizes)) 72 + response.TotalFiles = &totalFiles 73 + } 74 + 75 + writeJson(w, response) 76 + }
+81
knotserver/xrpc/repo_log.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + ref := r.URL.Query().Get("ref") 21 + 22 + path := r.URL.Query().Get("path") 23 + cursor := r.URL.Query().Get("cursor") 24 + 25 + limit := 50 // default 26 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + gr, err := git.Open(repoPath, ref) 33 + if err != nil { 34 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 35 + return 36 + } 37 + 38 + offset := 0 39 + if cursor != "" { 40 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 41 + offset = o 42 + } 43 + } 44 + 45 + commits, err := gr.Commits(offset, limit) 46 + if err != nil { 47 + x.Logger.Error("fetching commits", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("PathNotFound"), 50 + xrpcerr.WithMessage("failed to read commit log"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + total, err := gr.TotalCommits() 56 + if err != nil { 57 + x.Logger.Error("fetching total commits", "error", err.Error()) 58 + writeError(w, xrpcerr.NewXrpcError( 59 + xrpcerr.WithTag("InternalServerError"), 60 + xrpcerr.WithMessage("failed to fetch total commits"), 61 + ), http.StatusNotFound) 62 + return 63 + } 64 + 65 + // Create response using existing types.RepoLogResponse 66 + response := types.RepoLogResponse{ 67 + Commits: commits, 68 + Ref: ref, 69 + Page: (offset / limit) + 1, 70 + PerPage: limit, 71 + Total: total, 72 + } 73 + 74 + if path != "" { 75 + response.Description = path 76 + } 77 + 78 + response.Log = true 79 + 80 + writeJson(w, response) 81 + }
+86
knotserver/xrpc/repo_tags.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + cursor := r.URL.Query().Get("cursor") 24 + 25 + limit := 50 // default 26 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + x.Logger.Error("failed to open", "error", err) 35 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 36 + return 37 + } 38 + 39 + tags, err := gr.Tags() 40 + if err != nil { 41 + x.Logger.Warn("getting tags", "error", err.Error()) 42 + tags = []object.Tag{} 43 + } 44 + 45 + rtags := []*types.TagReference{} 46 + for _, tag := range tags { 47 + var target *object.Tag 48 + if tag.Target != plumbing.ZeroHash { 49 + target = &tag 50 + } 51 + tr := types.TagReference{ 52 + Tag: target, 53 + } 54 + 55 + tr.Reference = types.Reference{ 56 + Name: tag.Name, 57 + Hash: tag.Hash.String(), 58 + } 59 + 60 + if tag.Message != "" { 61 + tr.Message = tag.Message 62 + } 63 + 64 + rtags = append(rtags, &tr) 65 + } 66 + 67 + // apply pagination manually 68 + offset := 0 69 + if cursor != "" { 70 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 71 + offset = o 72 + } 73 + } 74 + 75 + // calculate end index 76 + end := min(offset+limit, len(rtags)) 77 + 78 + paginatedTags := rtags[offset:end] 79 + 80 + // Create response using existing types.RepoTagsResponse 81 + response := types.RepoTagsResponse{ 82 + Tags: paginatedTags, 83 + } 84 + 85 + writeJson(w, response) 86 + }
+113
knotserver/xrpc/repo_tree.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "path/filepath" 6 + "time" 7 + "unicode/utf8" 8 + 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { 16 + ctx := r.Context() 17 + 18 + repo := r.URL.Query().Get("repo") 19 + repoPath, err := x.parseRepoParam(repo) 20 + if err != nil { 21 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + ref := r.URL.Query().Get("ref") 26 + // ref can be empty (git.Open handles this) 27 + 28 + path := r.URL.Query().Get("path") 29 + // path can be empty (defaults to root) 30 + 31 + gr, err := git.Open(repoPath, ref) 32 + if err != nil { 33 + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 34 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 35 + return 36 + } 37 + 38 + files, err := gr.FileTree(ctx, path) 39 + if err != nil { 40 + x.Logger.Error("failed to get file tree", "error", err, "path", path) 41 + writeError(w, xrpcerr.NewXrpcError( 42 + xrpcerr.WithTag("PathNotFound"), 43 + xrpcerr.WithMessage("failed to read repository tree"), 44 + ), http.StatusNotFound) 45 + return 46 + } 47 + 48 + // if any of these files are a readme candidate, pass along its blob contents too 49 + var readmeFileName string 50 + var readmeContents string 51 + for _, file := range files { 52 + if markup.IsReadmeFile(file.Name) { 53 + contents, err := gr.RawContent(filepath.Join(path, file.Name)) 54 + if err != nil { 55 + x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name) 56 + } 57 + 58 + if utf8.Valid(contents) { 59 + readmeFileName = file.Name 60 + readmeContents = string(contents) 61 + break 62 + } 63 + } 64 + } 65 + 66 + // convert NiceTree -> tangled.RepoTree_TreeEntry 67 + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 + for i, file := range files { 69 + entry := &tangled.RepoTree_TreeEntry{ 70 + Name: file.Name, 71 + Mode: file.Mode, 72 + Size: file.Size, 73 + Is_file: file.IsFile, 74 + Is_subtree: file.IsSubtree, 75 + } 76 + 77 + if file.LastCommit != nil { 78 + entry.Last_commit = &tangled.RepoTree_LastCommit{ 79 + Hash: file.LastCommit.Hash.String(), 80 + Message: file.LastCommit.Message, 81 + When: file.LastCommit.When.Format(time.RFC3339), 82 + } 83 + } 84 + 85 + treeEntries[i] = entry 86 + } 87 + 88 + var parentPtr *string 89 + if path != "" { 90 + parentPtr = &path 91 + } 92 + 93 + var dotdotPtr *string 94 + if path != "" { 95 + dotdot := filepath.Dir(path) 96 + if dotdot != "." { 97 + dotdotPtr = &dotdot 98 + } 99 + } 100 + 101 + response := tangled.RepoTree_Output{ 102 + Ref: ref, 103 + Parent: parentPtr, 104 + Dotdot: dotdotPtr, 105 + Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 110 + } 111 + 112 + writeJson(w, response) 113 + }
+4 -4
knotserver/xrpc/set_default_branch.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/knotserver/git" 14 - "tangled.sh/tangled.sh/core/rbac" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 15 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 const ActorDid string = "ActorDid"
+60
knotserver/xrpc/version.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "runtime/debug" 7 + 8 + "tangled.org/core/api/tangled" 9 + ) 10 + 11 + // version is set during build time. 12 + var version string 13 + 14 + func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 15 + if version == "" { 16 + info, ok := debug.ReadBuildInfo() 17 + if !ok { 18 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 19 + return 20 + } 21 + 22 + var modVer string 23 + var sha string 24 + var modified bool 25 + 26 + for _, mod := range info.Deps { 27 + if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" { 28 + modVer = mod.Version 29 + break 30 + } 31 + } 32 + 33 + for _, setting := range info.Settings { 34 + switch setting.Key { 35 + case "vcs.revision": 36 + sha = setting.Value 37 + case "vcs.modified": 38 + modified = setting.Value == "true" 39 + } 40 + } 41 + 42 + if modVer == "" { 43 + modVer = "unknown" 44 + } 45 + 46 + if sha == "" { 47 + version = modVer 48 + } else if modified { 49 + version = fmt.Sprintf("%s (%s with modifications)", modVer, sha) 50 + } else { 51 + version = fmt.Sprintf("%s (%s)", modVer, sha) 52 + } 53 + } 54 + 55 + response := tangled.KnotVersion_Output{ 56 + Version: version, 57 + } 58 + 59 + writeJson(w, response) 60 + }
+77 -9
knotserver/xrpc/xrpc.go
··· 4 4 "encoding/json" 5 5 "log/slog" 6 6 "net/http" 7 + "strings" 7 8 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/idresolver" 10 - "tangled.sh/tangled.sh/core/jetstream" 11 - "tangled.sh/tangled.sh/core/knotserver/config" 12 - "tangled.sh/tangled.sh/core/knotserver/db" 13 - "tangled.sh/tangled.sh/core/notifier" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/idresolver" 12 + "tangled.org/core/jetstream" 13 + "tangled.org/core/knotserver/config" 14 + "tangled.org/core/knotserver/db" 15 + "tangled.org/core/notifier" 16 + "tangled.org/core/rbac" 17 + xrpcerr "tangled.org/core/xrpc/errors" 18 + "tangled.org/core/xrpc/serviceauth" 17 19 18 20 "github.com/go-chi/chi/v5" 19 21 ) ··· 36 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 37 39 38 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 + r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 39 42 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 40 43 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 41 44 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) ··· 50 53 // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 54 // - use ETags on clients to keep requests to a minimum 52 55 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 56 + 57 + // repo query endpoints (no auth required) 58 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 59 + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 60 + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 61 + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 62 + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 63 + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 64 + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 65 + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 66 + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 67 + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 68 + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 69 + 70 + // knot query endpoints (no auth required) 71 + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 72 + r.Get("/"+tangled.KnotVersionNSID, x.Version) 73 + 74 + // service query endpoints (no auth required) 75 + r.Get("/"+tangled.OwnerNSID, x.Owner) 76 + 53 77 return r 54 78 } 55 79 80 + // parseRepoParam parses a repo parameter in 'did/repoName' format and returns 81 + // the full repository path on disk 82 + func (x *Xrpc) parseRepoParam(repo string) (string, error) { 83 + if repo == "" { 84 + return "", xrpcerr.NewXrpcError( 85 + xrpcerr.WithTag("InvalidRequest"), 86 + xrpcerr.WithMessage("missing repo parameter"), 87 + ) 88 + } 89 + 90 + // Parse repo string (did/repoName format) 91 + parts := strings.SplitN(repo, "/", 2) 92 + if len(parts) != 2 { 93 + return "", xrpcerr.NewXrpcError( 94 + xrpcerr.WithTag("InvalidRequest"), 95 + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), 96 + ) 97 + } 98 + 99 + did := parts[0] 100 + repoName := parts[1] 101 + 102 + // Construct repository path using the same logic as didPath 103 + didRepoPath, err := securejoin.SecureJoin(did, repoName) 104 + if err != nil { 105 + return "", xrpcerr.RepoNotFoundError 106 + } 107 + 108 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath) 109 + if err != nil { 110 + return "", xrpcerr.RepoNotFoundError 111 + } 112 + 113 + return repoPath, nil 114 + } 115 + 56 116 func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 57 117 w.Header().Set("Content-Type", "application/json") 58 118 w.WriteHeader(status) 59 119 json.NewEncoder(w).Encode(e) 60 120 } 121 + 122 + func writeJson(w http.ResponseWriter, response any) { 123 + w.Header().Set("Content-Type", "application/json") 124 + if err := json.NewEncoder(w).Encode(response); err != nil { 125 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 126 + return 127 + } 128 + }
+1 -1
lexicon-build-config.json
··· 3 3 "package": "tangled", 4 4 "prefix": "sh.tangled", 5 5 "outdir": "api/tangled", 6 - "import": "tangled.sh/tangled.sh/core/api/tangled", 6 + "import": "tangled.org/core/api/tangled", 7 7 "gen-server": true 8 8 } 9 9 ]
+9 -9
lexicons/issue/comment.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": ["issue", "body", "createdAt"], 12 + "required": [ 13 + "issue", 14 + "body", 15 + "createdAt" 16 + ], 13 17 "properties": { 14 18 "issue": { 15 19 "type": "string", 16 20 "format": "at-uri" 17 21 }, 18 - "repo": { 19 - "type": "string", 20 - "format": "at-uri" 21 - }, 22 - "owner": { 23 - "type": "string", 24 - "format": "did" 25 - }, 26 22 "body": { 27 23 "type": "string" 28 24 }, 29 25 "createdAt": { 30 26 "type": "string", 31 27 "format": "datetime" 28 + }, 29 + "replyTo": { 30 + "type": "string", 31 + "format": "at-uri" 32 32 } 33 33 } 34 34 }
+73
lexicons/knot/listKeys.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.listKeys", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List all public keys stored in the knot server", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "description": "Maximum number of keys to return", 14 + "minimum": 1, 15 + "maximum": 1000, 16 + "default": 100 17 + }, 18 + "cursor": { 19 + "type": "string", 20 + "description": "Pagination cursor" 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": ["keys"], 29 + "properties": { 30 + "keys": { 31 + "type": "array", 32 + "items": { 33 + "type": "ref", 34 + "ref": "#publicKey" 35 + } 36 + }, 37 + "cursor": { 38 + "type": "string", 39 + "description": "Pagination cursor for next page" 40 + } 41 + } 42 + } 43 + }, 44 + "errors": [ 45 + { 46 + "name": "InternalServerError", 47 + "description": "Failed to retrieve public keys" 48 + } 49 + ] 50 + }, 51 + "publicKey": { 52 + "type": "object", 53 + "required": ["did", "key", "createdAt"], 54 + "properties": { 55 + "did": { 56 + "type": "string", 57 + "format": "did", 58 + "description": "DID associated with the public key" 59 + }, 60 + "key": { 61 + "type": "string", 62 + "maxLength": 4096, 63 + "description": "Public key contents" 64 + }, 65 + "createdAt": { 66 + "type": "string", 67 + "format": "datetime", 68 + "description": "Key upload timestamp" 69 + } 70 + } 71 + } 72 + } 73 + }
+25
lexicons/knot/version.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.version", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the version of a knot", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "version" 14 + ], 15 + "properties": { 16 + "version": { 17 + "type": "string" 18 + } 19 + } 20 + } 21 + }, 22 + "errors": [] 23 + } 24 + } 25 + }
+89
lexicons/label/definition.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.label.definition", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "valueType", 15 + "scope", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "name": { 20 + "type": "string", 21 + "description": "The display name of this label.", 22 + "minGraphemes": 1, 23 + "maxGraphemes": 40 24 + }, 25 + "valueType": { 26 + "type": "ref", 27 + "ref": "#valueType", 28 + "description": "The type definition of this label. Appviews may allow sorting for certain types." 29 + }, 30 + "scope": { 31 + "type": "array", 32 + "description": "The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.", 33 + "items": { 34 + "type": "string", 35 + "format": "nsid" 36 + } 37 + }, 38 + "color": { 39 + "type": "string", 40 + "description": "The hex value for the background color for the label. Appviews may choose to respect this." 41 + }, 42 + "createdAt": { 43 + "type": "string", 44 + "format": "datetime" 45 + }, 46 + "multiple": { 47 + "type": "boolean", 48 + "description": "Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]" 49 + } 50 + } 51 + } 52 + }, 53 + "valueType": { 54 + "type": "object", 55 + "required": [ 56 + "type", 57 + "format" 58 + ], 59 + "properties": { 60 + "type": { 61 + "type": "string", 62 + "enum": [ 63 + "null", 64 + "boolean", 65 + "integer", 66 + "string" 67 + ], 68 + "description": "The concrete type of this label's value." 69 + }, 70 + "format": { 71 + "type": "string", 72 + "enum": [ 73 + "any", 74 + "did", 75 + "nsid" 76 + ], 77 + "description": "An optional constraint that can be applied on string concrete types." 78 + }, 79 + "enum": { 80 + "type": "array", 81 + "description": "Closed set of values that this label can take.", 82 + "items": { 83 + "type": "string" 84 + } 85 + } 86 + } 87 + } 88 + } 89 + }
+64
lexicons/label/op.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.label.op", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "add", 15 + "delete", 16 + "performedAt" 17 + ], 18 + "properties": { 19 + "subject": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op." 23 + }, 24 + "performedAt": { 25 + "type": "string", 26 + "format": "datetime" 27 + }, 28 + "add": { 29 + "type": "array", 30 + "items": { 31 + "type": "ref", 32 + "ref": "#operand" 33 + } 34 + }, 35 + "delete": { 36 + "type": "array", 37 + "items": { 38 + "type": "ref", 39 + "ref": "#operand" 40 + } 41 + } 42 + } 43 + } 44 + }, 45 + "operand": { 46 + "type": "object", 47 + "required": [ 48 + "key", 49 + "value" 50 + ], 51 + "properties": { 52 + "key": { 53 + "type": "string", 54 + "format": "at-uri", 55 + "description": "ATURI to the label definition" 56 + }, 57 + "value": { 58 + "type": "string", 59 + "description": "Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value." 60 + } 61 + } 62 + } 63 + } 64 + }
+31
lexicons/owner.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.owner", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the owner of a service", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "owner" 14 + ], 15 + "properties": { 16 + "owner": { 17 + "type": "string", 18 + "format": "did" 19 + } 20 + } 21 + } 22 + }, 23 + "errors": [ 24 + { 25 + "name": "OwnerNotFound", 26 + "description": "Owner is not set for this service" 27 + } 28 + ] 29 + } 30 + } 31 + }
+55
lexicons/repo/archive.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.archive", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "format": { 20 + "type": "string", 21 + "description": "Archive format", 22 + "enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"], 23 + "default": "tar.gz" 24 + }, 25 + "prefix": { 26 + "type": "string", 27 + "description": "Prefix for files in the archive" 28 + } 29 + } 30 + }, 31 + "output": { 32 + "encoding": "*/*", 33 + "description": "Binary archive data" 34 + }, 35 + "errors": [ 36 + { 37 + "name": "RepoNotFound", 38 + "description": "Repository not found or access denied" 39 + }, 40 + { 41 + "name": "RefNotFound", 42 + "description": "Git reference not found" 43 + }, 44 + { 45 + "name": "InvalidRequest", 46 + "description": "Invalid request parameters" 47 + }, 48 + { 49 + "name": "ArchiveError", 50 + "description": "Failed to create archive" 51 + } 52 + ] 53 + } 54 + } 55 + }
+138
lexicons/repo/blob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.blob", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref", "path"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to the file within the repository" 22 + }, 23 + "raw": { 24 + "type": "boolean", 25 + "description": "Return raw file content instead of JSON response", 26 + "default": false 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["ref", "path", "content"], 35 + "properties": { 36 + "ref": { 37 + "type": "string", 38 + "description": "The git reference used" 39 + }, 40 + "path": { 41 + "type": "string", 42 + "description": "The file path" 43 + }, 44 + "content": { 45 + "type": "string", 46 + "description": "File content (base64 encoded for binary files)" 47 + }, 48 + "encoding": { 49 + "type": "string", 50 + "description": "Content encoding", 51 + "enum": ["utf-8", "base64"] 52 + }, 53 + "size": { 54 + "type": "integer", 55 + "description": "File size in bytes" 56 + }, 57 + "isBinary": { 58 + "type": "boolean", 59 + "description": "Whether the file is binary" 60 + }, 61 + "mimeType": { 62 + "type": "string", 63 + "description": "MIME type of the file" 64 + }, 65 + "lastCommit": { 66 + "type": "ref", 67 + "ref": "#lastCommit" 68 + } 69 + } 70 + } 71 + }, 72 + "errors": [ 73 + { 74 + "name": "RepoNotFound", 75 + "description": "Repository not found or access denied" 76 + }, 77 + { 78 + "name": "RefNotFound", 79 + "description": "Git reference not found" 80 + }, 81 + { 82 + "name": "FileNotFound", 83 + "description": "File not found at the specified path" 84 + }, 85 + { 86 + "name": "InvalidRequest", 87 + "description": "Invalid request parameters" 88 + } 89 + ] 90 + }, 91 + "lastCommit": { 92 + "type": "object", 93 + "required": ["hash", "message", "when"], 94 + "properties": { 95 + "hash": { 96 + "type": "string", 97 + "description": "Commit hash" 98 + }, 99 + "shortHash": { 100 + "type": "string", 101 + "description": "Short commit hash" 102 + }, 103 + "message": { 104 + "type": "string", 105 + "description": "Commit message" 106 + }, 107 + "author": { 108 + "type": "ref", 109 + "ref": "#signature" 110 + }, 111 + "when": { 112 + "type": "string", 113 + "format": "datetime", 114 + "description": "Commit timestamp" 115 + } 116 + } 117 + }, 118 + "signature": { 119 + "type": "object", 120 + "required": ["name", "email", "when"], 121 + "properties": { 122 + "name": { 123 + "type": "string", 124 + "description": "Author name" 125 + }, 126 + "email": { 127 + "type": "string", 128 + "description": "Author email" 129 + }, 130 + "when": { 131 + "type": "string", 132 + "format": "datetime", 133 + "description": "Author timestamp" 134 + } 135 + } 136 + } 137 + } 138 + }
+94
lexicons/repo/branch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "name"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "name": { 16 + "type": "string", 17 + "description": "Branch name to get information for" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["name", "hash", "when"], 26 + "properties": { 27 + "name": { 28 + "type": "string", 29 + "description": "Branch name" 30 + }, 31 + "hash": { 32 + "type": "string", 33 + "description": "Latest commit hash on this branch" 34 + }, 35 + "shortHash": { 36 + "type": "string", 37 + "description": "Short commit hash" 38 + }, 39 + "when": { 40 + "type": "string", 41 + "format": "datetime", 42 + "description": "Timestamp of latest commit" 43 + }, 44 + "message": { 45 + "type": "string", 46 + "description": "Latest commit message" 47 + }, 48 + "author": { 49 + "type": "ref", 50 + "ref": "#signature" 51 + }, 52 + "isDefault": { 53 + "type": "boolean", 54 + "description": "Whether this is the default branch" 55 + } 56 + } 57 + } 58 + }, 59 + "errors": [ 60 + { 61 + "name": "RepoNotFound", 62 + "description": "Repository not found or access denied" 63 + }, 64 + { 65 + "name": "BranchNotFound", 66 + "description": "Branch not found" 67 + }, 68 + { 69 + "name": "InvalidRequest", 70 + "description": "Invalid request parameters" 71 + } 72 + ] 73 + }, 74 + "signature": { 75 + "type": "object", 76 + "required": ["name", "email", "when"], 77 + "properties": { 78 + "name": { 79 + "type": "string", 80 + "description": "Author name" 81 + }, 82 + "email": { 83 + "type": "string", 84 + "description": "Author email" 85 + }, 86 + "when": { 87 + "type": "string", 88 + "format": "datetime", 89 + "description": "Author timestamp" 90 + } 91 + } 92 + } 93 + } 94 + }
+43
lexicons/repo/branches.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branches", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of branches to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+49
lexicons/repo/compare.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.compare", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "rev1", "rev2"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "rev1": { 16 + "type": "string", 17 + "description": "First revision (commit, branch, or tag)" 18 + }, 19 + "rev2": { 20 + "type": "string", 21 + "description": "Second revision (commit, branch, or tag)" 22 + } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "*/*", 27 + "description": "Compare output in application/json" 28 + }, 29 + "errors": [ 30 + { 31 + "name": "RepoNotFound", 32 + "description": "Repository not found or access denied" 33 + }, 34 + { 35 + "name": "RevisionNotFound", 36 + "description": "One or both revisions not found" 37 + }, 38 + { 39 + "name": "InvalidRequest", 40 + "description": "Invalid request parameters" 41 + }, 42 + { 43 + "name": "CompareError", 44 + "description": "Failed to compare revisions" 45 + } 46 + ] 47 + } 48 + } 49 + }
+30
lexicons/repo/deleteBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.deleteBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a branch on this repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "branch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "branch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + } 30 +
+40
lexicons/repo/diff.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.diff", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "*/*" 23 + }, 24 + "errors": [ 25 + { 26 + "name": "RepoNotFound", 27 + "description": "Repository not found or access denied" 28 + }, 29 + { 30 + "name": "RefNotFound", 31 + "description": "Git reference not found" 32 + }, 33 + { 34 + "name": "InvalidRequest", 35 + "description": "Invalid request parameters" 36 + } 37 + ] 38 + } 39 + } 40 + }
+82
lexicons/repo/getDefaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.getDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["name", "hash", "when"], 22 + "properties": { 23 + "name": { 24 + "type": "string", 25 + "description": "Default branch name" 26 + }, 27 + "hash": { 28 + "type": "string", 29 + "description": "Latest commit hash on default branch" 30 + }, 31 + "shortHash": { 32 + "type": "string", 33 + "description": "Short commit hash" 34 + }, 35 + "when": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "Timestamp of latest commit" 39 + }, 40 + "message": { 41 + "type": "string", 42 + "description": "Latest commit message" 43 + }, 44 + "author": { 45 + "type": "ref", 46 + "ref": "#signature" 47 + } 48 + } 49 + } 50 + }, 51 + "errors": [ 52 + { 53 + "name": "RepoNotFound", 54 + "description": "Repository not found or access denied" 55 + }, 56 + { 57 + "name": "InvalidRequest", 58 + "description": "Invalid request parameters" 59 + } 60 + ] 61 + }, 62 + "signature": { 63 + "type": "object", 64 + "required": ["name", "email", "when"], 65 + "properties": { 66 + "name": { 67 + "type": "string", 68 + "description": "Author name" 69 + }, 70 + "email": { 71 + "type": "string", 72 + "description": "Author email" 73 + }, 74 + "when": { 75 + "type": "string", 76 + "format": "datetime", 77 + "description": "Author timestamp" 78 + } 79 + } 80 + } 81 + } 82 + }
+99
lexicons/repo/languages.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.languages", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)", 18 + "default": "HEAD" 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["ref", "languages"], 27 + "properties": { 28 + "ref": { 29 + "type": "string", 30 + "description": "The git reference used" 31 + }, 32 + "languages": { 33 + "type": "array", 34 + "items": { 35 + "type": "ref", 36 + "ref": "#language" 37 + } 38 + }, 39 + "totalSize": { 40 + "type": "integer", 41 + "description": "Total size of all analyzed files in bytes" 42 + }, 43 + "totalFiles": { 44 + "type": "integer", 45 + "description": "Total number of files analyzed" 46 + } 47 + } 48 + } 49 + }, 50 + "errors": [ 51 + { 52 + "name": "RepoNotFound", 53 + "description": "Repository not found or access denied" 54 + }, 55 + { 56 + "name": "RefNotFound", 57 + "description": "Git reference not found" 58 + }, 59 + { 60 + "name": "InvalidRequest", 61 + "description": "Invalid request parameters" 62 + } 63 + ] 64 + }, 65 + "language": { 66 + "type": "object", 67 + "required": ["name", "size", "percentage"], 68 + "properties": { 69 + "name": { 70 + "type": "string", 71 + "description": "Programming language name" 72 + }, 73 + "size": { 74 + "type": "integer", 75 + "description": "Total size of files in this language (bytes)" 76 + }, 77 + "percentage": { 78 + "type": "integer", 79 + "description": "Percentage of total codebase (0-100)" 80 + }, 81 + "fileCount": { 82 + "type": "integer", 83 + "description": "Number of files in this language" 84 + }, 85 + "color": { 86 + "type": "string", 87 + "description": "Hex color code for this language" 88 + }, 89 + "extensions": { 90 + "type": "array", 91 + "items": { 92 + "type": "string" 93 + }, 94 + "description": "File extensions associated with this language" 95 + } 96 + } 97 + } 98 + } 99 + }
+60
lexicons/repo/log.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.log", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to filter commits by", 22 + "default": "" 23 + }, 24 + "limit": { 25 + "type": "integer", 26 + "description": "Maximum number of commits to return", 27 + "minimum": 1, 28 + "maximum": 100, 29 + "default": 50 30 + }, 31 + "cursor": { 32 + "type": "string", 33 + "description": "Pagination cursor (commit SHA)" 34 + } 35 + } 36 + }, 37 + "output": { 38 + "encoding": "*/*" 39 + }, 40 + "errors": [ 41 + { 42 + "name": "RepoNotFound", 43 + "description": "Repository not found or access denied" 44 + }, 45 + { 46 + "name": "RefNotFound", 47 + "description": "Git reference not found" 48 + }, 49 + { 50 + "name": "PathNotFound", 51 + "description": "Path not found in repository" 52 + }, 53 + { 54 + "name": "InvalidRequest", 55 + "description": "Invalid request parameters" 56 + } 57 + ] 58 + } 59 + } 60 + }
+8 -6
lexicons/repo/repo.json
··· 12 12 "required": [ 13 13 "name", 14 14 "knot", 15 - "owner", 16 15 "createdAt" 17 16 ], 18 17 "properties": { ··· 20 19 "type": "string", 21 20 "description": "name of the repo" 22 21 }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 - }, 27 22 "knot": { 28 23 "type": "string", 29 24 "description": "knot where the repo was created" ··· 34 29 }, 35 30 "description": { 36 31 "type": "string", 37 - "format": "datetime", 38 32 "minGraphemes": 1, 39 33 "maxGraphemes": 140 40 34 }, ··· 42 36 "type": "string", 43 37 "format": "uri", 44 38 "description": "source of the repo" 39 + }, 40 + "labels": { 41 + "type": "array", 42 + "description": "List of labels that this repo subscribes to", 43 + "items": { 44 + "type": "string", 45 + "format": "at-uri" 46 + } 45 47 }, 46 48 "createdAt": { 47 49 "type": "string",
+43
lexicons/repo/tags.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tags", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of tags to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+142
lexicons/repo/tree.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tree", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path within the repository tree", 22 + "default": "" 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["ref", "files"], 31 + "properties": { 32 + "ref": { 33 + "type": "string", 34 + "description": "The git reference used" 35 + }, 36 + "parent": { 37 + "type": "string", 38 + "description": "The parent path in the tree" 39 + }, 40 + "dotdot": { 41 + "type": "string", 42 + "description": "Parent directory path" 43 + }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 49 + "files": { 50 + "type": "array", 51 + "items": { 52 + "type": "ref", 53 + "ref": "#treeEntry" 54 + } 55 + } 56 + } 57 + } 58 + }, 59 + "errors": [ 60 + { 61 + "name": "RepoNotFound", 62 + "description": "Repository not found or access denied" 63 + }, 64 + { 65 + "name": "RefNotFound", 66 + "description": "Git reference not found" 67 + }, 68 + { 69 + "name": "PathNotFound", 70 + "description": "Path not found in repository tree" 71 + }, 72 + { 73 + "name": "InvalidRequest", 74 + "description": "Invalid request parameters" 75 + } 76 + ] 77 + }, 78 + "readme": { 79 + "type": "object", 80 + "required": ["filename", "contents"], 81 + "properties": { 82 + "filename": { 83 + "type": "string", 84 + "description": "Name of the readme file" 85 + }, 86 + "contents": { 87 + "type": "string", 88 + "description": "Contents of the readme file" 89 + } 90 + } 91 + }, 92 + "treeEntry": { 93 + "type": "object", 94 + "required": ["name", "mode", "size", "is_file", "is_subtree"], 95 + "properties": { 96 + "name": { 97 + "type": "string", 98 + "description": "Relative file or directory name" 99 + }, 100 + "mode": { 101 + "type": "string", 102 + "description": "File mode" 103 + }, 104 + "size": { 105 + "type": "integer", 106 + "description": "File size in bytes" 107 + }, 108 + "is_file": { 109 + "type": "boolean", 110 + "description": "Whether this entry is a file" 111 + }, 112 + "is_subtree": { 113 + "type": "boolean", 114 + "description": "Whether this entry is a directory/subtree" 115 + }, 116 + "last_commit": { 117 + "type": "ref", 118 + "ref": "#lastCommit" 119 + } 120 + } 121 + }, 122 + "lastCommit": { 123 + "type": "object", 124 + "required": ["hash", "message", "when"], 125 + "properties": { 126 + "hash": { 127 + "type": "string", 128 + "description": "Commit hash" 129 + }, 130 + "message": { 131 + "type": "string", 132 + "description": "Commit message" 133 + }, 134 + "when": { 135 + "type": "string", 136 + "format": "datetime", 137 + "description": "Commit timestamp" 138 + } 139 + } 140 + } 141 + } 142 + }
+29 -11
nix/gomod2nix.toml
··· 40 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 42 [mod."github.com/bluesky-social/indigo"] 43 - version = "v0.0.0-20250724221105-5827c8fb61bb" 44 - hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 43 + version = "v0.0.0-20251003000214-3259b215110e" 44 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 45 45 [mod."github.com/bluesky-social/jetstream"] 46 46 version = "v0.0.0-20241210005130-ea96859b93d1" 47 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 163 163 [mod."github.com/gogo/protobuf"] 164 164 version = "v1.3.2" 165 165 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 166 + [mod."github.com/goki/freetype"] 167 + version = "v1.0.5" 168 + hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 166 169 [mod."github.com/golang-jwt/jwt/v5"] 167 170 version = "v5.2.3" 168 171 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 407 410 [mod."github.com/spaolacci/murmur3"] 408 411 version = "v1.1.0" 409 412 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 413 + [mod."github.com/srwiley/oksvg"] 414 + version = "v0.0.0-20221011165216-be6e8873101c" 415 + hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk=" 416 + [mod."github.com/srwiley/rasterx"] 417 + version = "v0.0.0-20220730225603-2ab79fcdd4ef" 418 + hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68=" 410 419 [mod."github.com/stretchr/testify"] 411 420 version = "v1.10.0" 412 421 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 425 434 [mod."github.com/whyrusleeping/cbor-gen"] 426 435 version = "v0.3.1" 427 436 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 437 + [mod."github.com/wyatt915/goldmark-treeblood"] 438 + version = "v0.0.0-20250825231212-5dcbdb2f4b57" 439 + hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 440 + [mod."github.com/wyatt915/treeblood"] 441 + version = "v0.1.15" 442 + hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 428 443 [mod."github.com/yuin/goldmark"] 429 - version = "v1.4.15" 430 - hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0=" 444 + version = "v1.7.13" 445 + hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 431 446 [mod."github.com/yuin/goldmark-highlighting/v2"] 432 447 version = "v2.0.0-20230729083705-37449abec8cc" 433 448 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 449 + [mod."gitlab.com/staticnoise/goldmark-callout"] 450 + version = "v0.0.0-20240609120641-6366b799e4ab" 451 + hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44=" 434 452 [mod."gitlab.com/yawning/secp256k1-voi"] 435 453 version = "v0.0.0-20230925100816-f2616030848b" 436 454 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 473 491 [mod."golang.org/x/exp"] 474 492 version = "v0.0.0-20250620022241-b7579e27df2b" 475 493 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 494 + [mod."golang.org/x/image"] 495 + version = "v0.31.0" 496 + hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 476 497 [mod."golang.org/x/net"] 477 498 version = "v0.42.0" 478 499 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 479 500 [mod."golang.org/x/sync"] 480 - version = "v0.16.0" 481 - hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 501 + version = "v0.17.0" 502 + hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 482 503 [mod."golang.org/x/sys"] 483 504 version = "v0.34.0" 484 505 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 485 506 [mod."golang.org/x/text"] 486 - version = "v0.27.0" 487 - hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 507 + version = "v0.29.0" 508 + hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" 488 509 [mod."golang.org/x/time"] 489 510 version = "v0.12.0" 490 511 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 521 542 [mod."lukechampine.com/blake3"] 522 543 version = "v1.4.1" 523 544 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 524 - [mod."tangled.sh/icyphox.sh/atproto-oauth"] 525 - version = "v0.0.0-20250724194903-28e660378cb1" 526 - hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+1
nix/pkgs/appview-static-files.nix
··· 22 22 cp -rf ${lucide-src}/*.svg icons/ 23 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 25 26 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 27 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 28 # for whatever reason (produces broken css), so we are doing this instead
+17 -12
nix/pkgs/knot-unwrapped.nix
··· 3 3 modules, 4 4 sqlite-lib, 5 5 src, 6 - }: 7 - buildGoApplication { 8 - pname = "knot"; 9 - version = "0.1.0"; 10 - inherit src modules; 6 + }: let 7 + version = "1.9.1-alpha"; 8 + in 9 + buildGoApplication { 10 + pname = "knot"; 11 + inherit src version modules; 12 + 13 + doCheck = false; 11 14 12 - doCheck = false; 15 + subPackages = ["cmd/knot"]; 16 + tags = ["libsqlite3"]; 13 17 14 - subPackages = ["cmd/knot"]; 15 - tags = ["libsqlite3"]; 18 + ldflags = [ 19 + "-X tangled.org/core/knotserver/xrpc.version=${version}" 20 + ]; 16 21 17 - env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 18 - env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 19 - CGO_ENABLED = 1; 20 - } 22 + env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 23 + env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 24 + CGO_ENABLED = 1; 25 + }
+1 -1
patchutil/combinediff.go
··· 119 119 // we have f1 and f2, combine them 120 120 combined, err := combineFiles(f1, f2) 121 121 if err != nil { 122 - fmt.Println(err) 122 + // fmt.Println(err) 123 123 } 124 124 125 125 // combined can be nil commit 2 reverted all changes from commit 1
+1 -1
patchutil/interdiff.go
··· 5 5 "strings" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/types" 8 + "tangled.org/core/types" 9 9 ) 10 10 11 11 type InterdiffResult struct {
+1 -1
patchutil/patchutil.go
··· 10 10 "strings" 11 11 12 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 - "tangled.sh/tangled.sh/core/types" 13 + "tangled.org/core/types" 14 14 ) 15 15 16 16 func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
+1 -1
rbac/rbac_test.go
··· 4 4 "database/sql" 5 5 "testing" 6 6 7 - "tangled.sh/tangled.sh/core/rbac" 7 + "tangled.org/core/rbac" 8 8 9 9 adapter "github.com/Blank-Xu/sql-adapter" 10 10 "github.com/casbin/casbin/v2"
+4 -4
readme.md
··· 1 1 # tangled 2 2 3 3 Hello Tanglers! This is the codebase for 4 - [Tangled](https://tangled.sh)&mdash;a code collaboration platform built 4 + [Tangled](https://tangled.org)&mdash;a code collaboration platform built 5 5 on the [AT Protocol](https://atproto.com). 6 6 7 - Read the introduction to Tangled [here](https://blog.tangled.sh/intro). Join the 8 - [Discord](https://chat.tangled.sh) or IRC at [#tangled on 7 + Read the introduction to Tangled [here](https://blog.tangled.org/intro). Join the 8 + [Discord](https://chat.tangled.org) or IRC at [#tangled on 9 9 libera.chat](https://web.libera.chat/#tangled). 10 10 11 11 ## docs ··· 17 17 ## security 18 18 19 19 If you've identified a security issue in Tangled, please email 20 - [security@tangled.sh](mailto:security@tangled.sh) with details! 20 + [security@tangled.org](mailto:security@tangled.org) with details!
+4 -4
spindle/db/events.go
··· 5 5 "fmt" 6 6 "time" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/notifier" 10 - "tangled.sh/tangled.sh/core/spindle/models" 11 - "tangled.sh/tangled.sh/core/tid" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/notifier" 10 + "tangled.org/core/spindle/models" 11 + "tangled.org/core/tid" 12 12 ) 13 13 14 14 type Event struct {
+5 -5
spindle/engine/engine.go
··· 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 10 "golang.org/x/sync/errgroup" 11 - "tangled.sh/tangled.sh/core/notifier" 12 - "tangled.sh/tangled.sh/core/spindle/config" 13 - "tangled.sh/tangled.sh/core/spindle/db" 14 - "tangled.sh/tangled.sh/core/spindle/models" 15 - "tangled.sh/tangled.sh/core/spindle/secrets" 11 + "tangled.org/core/notifier" 12 + "tangled.org/core/spindle/config" 13 + "tangled.org/core/spindle/db" 14 + "tangled.org/core/spindle/models" 15 + "tangled.org/core/spindle/secrets" 16 16 ) 17 17 18 18 var (
+6 -6
spindle/engines/nixery/engine.go
··· 19 19 "github.com/docker/docker/client" 20 20 "github.com/docker/docker/pkg/stdcopy" 21 21 "gopkg.in/yaml.v3" 22 - "tangled.sh/tangled.sh/core/api/tangled" 23 - "tangled.sh/tangled.sh/core/log" 24 - "tangled.sh/tangled.sh/core/spindle/config" 25 - "tangled.sh/tangled.sh/core/spindle/engine" 26 - "tangled.sh/tangled.sh/core/spindle/models" 27 - "tangled.sh/tangled.sh/core/spindle/secrets" 22 + "tangled.org/core/api/tangled" 23 + "tangled.org/core/log" 24 + "tangled.org/core/spindle/config" 25 + "tangled.org/core/spindle/engine" 26 + "tangled.org/core/spindle/models" 27 + "tangled.org/core/spindle/secrets" 28 28 ) 29 29 30 30 const (
+2 -2
spindle/engines/nixery/setup_steps.go
··· 5 5 "path" 6 6 "strings" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/workflow" 10 10 ) 11 11 12 12 func nixConfStep() Step {
+11 -11
spindle/ingester.go
··· 7 7 "fmt" 8 8 "time" 9 9 10 - "tangled.sh/tangled.sh/core/api/tangled" 11 - "tangled.sh/tangled.sh/core/eventconsumer" 12 - "tangled.sh/tangled.sh/core/idresolver" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - "tangled.sh/tangled.sh/core/spindle/db" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/eventconsumer" 12 + "tangled.org/core/idresolver" 13 + "tangled.org/core/rbac" 14 + "tangled.org/core/spindle/db" 15 15 16 16 comatproto "github.com/bluesky-social/indigo/api/atproto" 17 17 "github.com/bluesky-social/indigo/atproto/identity" ··· 146 146 147 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 148 148 149 - l.Info("ingesting repo record") 149 + l.Info("ingesting repo record", "did", did) 150 150 151 151 switch e.Commit.Operation { 152 152 case models.CommitOperationCreate, models.CommitOperationUpdate: ··· 162 162 163 163 // no spindle configured for this repo 164 164 if record.Spindle == nil { 165 - l.Info("no spindle configured", "did", record.Owner, "name", record.Name) 165 + l.Info("no spindle configured", "name", record.Name) 166 166 return nil 167 167 } 168 168 169 169 // this repo did not want this spindle 170 170 if *record.Spindle != domain { 171 - l.Info("different spindle configured", "did", record.Owner, "name", record.Name, "spindle", *record.Spindle, "domain", domain) 171 + l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 172 172 return nil 173 173 } 174 174 175 175 // add this repo to the watch list 176 - if err := s.db.AddRepo(record.Knot, record.Owner, record.Name); err != nil { 176 + if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil { 177 177 l.Error("failed to add repo", "error", err) 178 178 return fmt.Errorf("failed to add repo: %w", err) 179 179 } 180 180 181 - didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 181 + didSlashRepo, err := securejoin.SecureJoin(did, record.Name) 182 182 if err != nil { 183 183 return err 184 184 } 185 185 186 186 // add repo to rbac 187 - if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 187 + if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil { 188 188 l.Error("failed to add repo to enforcer", "error", err) 189 189 return fmt.Errorf("failed to add repo: %w", err) 190 190 }
+2 -2
spindle/models/engine.go
··· 4 4 "context" 5 5 "time" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - "tangled.sh/tangled.sh/core/spindle/secrets" 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/spindle/secrets" 9 9 ) 10 10 11 11 type Engine interface {
+1 -1
spindle/models/models.go
··· 5 5 "regexp" 6 6 "slices" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.org/core/api/tangled" 9 9 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 )
+17 -20
spindle/server.go
··· 9 9 "net/http" 10 10 11 11 "github.com/go-chi/chi/v5" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/eventconsumer" 14 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 15 - "tangled.sh/tangled.sh/core/idresolver" 16 - "tangled.sh/tangled.sh/core/jetstream" 17 - "tangled.sh/tangled.sh/core/log" 18 - "tangled.sh/tangled.sh/core/notifier" 19 - "tangled.sh/tangled.sh/core/rbac" 20 - "tangled.sh/tangled.sh/core/spindle/config" 21 - "tangled.sh/tangled.sh/core/spindle/db" 22 - "tangled.sh/tangled.sh/core/spindle/engine" 23 - "tangled.sh/tangled.sh/core/spindle/engines/nixery" 24 - "tangled.sh/tangled.sh/core/spindle/models" 25 - "tangled.sh/tangled.sh/core/spindle/queue" 26 - "tangled.sh/tangled.sh/core/spindle/secrets" 27 - "tangled.sh/tangled.sh/core/spindle/xrpc" 28 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/eventconsumer" 14 + "tangled.org/core/eventconsumer/cursor" 15 + "tangled.org/core/idresolver" 16 + "tangled.org/core/jetstream" 17 + "tangled.org/core/log" 18 + "tangled.org/core/notifier" 19 + "tangled.org/core/rbac" 20 + "tangled.org/core/spindle/config" 21 + "tangled.org/core/spindle/db" 22 + "tangled.org/core/spindle/engine" 23 + "tangled.org/core/spindle/engines/nixery" 24 + "tangled.org/core/spindle/models" 25 + "tangled.org/core/spindle/queue" 26 + "tangled.org/core/spindle/secrets" 27 + "tangled.org/core/spindle/xrpc" 28 + "tangled.org/core/xrpc/serviceauth" 29 29 ) 30 30 31 31 //go:embed motd ··· 203 203 w.Write(motd) 204 204 }) 205 205 mux.HandleFunc("/events", s.Events) 206 - mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 207 - w.Write([]byte(s.cfg.Server.Owner)) 208 - }) 209 206 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 210 207 211 208 mux.Mount("/xrpc", s.XrpcRouter())
+1 -1
spindle/stream.go
··· 10 10 "strconv" 11 11 "time" 12 12 13 - "tangled.sh/tangled.sh/core/spindle/models" 13 + "tangled.org/core/spindle/models" 14 14 15 15 "github.com/go-chi/chi/v5" 16 16 "github.com/gorilla/websocket"
+5 -5
spindle/xrpc/add_secret.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - "tangled.sh/tangled.sh/core/spindle/secrets" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 15 + "tangled.org/core/spindle/secrets" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { ··· 62 62 } 63 63 64 64 repo := resp.Value.Val.(*tangled.Repo) 65 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 66 66 if err != nil { 67 67 fail(xrpcerr.GenericError(err)) 68 68 return
+5 -5
spindle/xrpc/list_secrets.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - "tangled.sh/tangled.sh/core/spindle/secrets" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 15 + "tangled.org/core/spindle/secrets" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { ··· 57 57 } 58 58 59 59 repo := resp.Value.Val.(*tangled.Repo) 60 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 61 if err != nil { 62 62 fail(xrpcerr.GenericError(err)) 63 63 return
+31
spindle/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 + owner := x.Config.Server.Owner 13 + if owner == "" { 14 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 + return 16 + } 17 + 18 + response := tangled.Owner_Output{ 19 + Owner: owner, 20 + } 21 + 22 + w.Header().Set("Content-Type", "application/json") 23 + if err := json.NewEncoder(w).Encode(response); err != nil { 24 + x.Logger.Error("failed to encode response", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to encode response"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + }
+5 -5
spindle/xrpc/remove_secret.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - "tangled.sh/tangled.sh/core/spindle/secrets" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/rbac" 14 + "tangled.org/core/spindle/secrets" 15 + xrpcerr "tangled.org/core/xrpc/errors" 16 16 ) 17 17 18 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { ··· 56 56 } 57 57 58 58 repo := resp.Value.Val.(*tangled.Repo) 59 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 60 60 if err != nil { 61 61 fail(xrpcerr.GenericError(err)) 62 62 return
+19 -12
spindle/xrpc/xrpc.go
··· 8 8 9 9 "github.com/go-chi/chi/v5" 10 10 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/idresolver" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - "tangled.sh/tangled.sh/core/spindle/config" 15 - "tangled.sh/tangled.sh/core/spindle/db" 16 - "tangled.sh/tangled.sh/core/spindle/models" 17 - "tangled.sh/tangled.sh/core/spindle/secrets" 18 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/idresolver" 13 + "tangled.org/core/rbac" 14 + "tangled.org/core/spindle/config" 15 + "tangled.org/core/spindle/db" 16 + "tangled.org/core/spindle/models" 17 + "tangled.org/core/spindle/secrets" 18 + xrpcerr "tangled.org/core/xrpc/errors" 19 + "tangled.org/core/xrpc/serviceauth" 20 20 ) 21 21 22 22 const ActorDid string = "ActorDid" ··· 35 35 func (x *Xrpc) Router() http.Handler { 36 36 r := chi.NewRouter() 37 37 38 - r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 39 - r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 40 - r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 38 + r.Group(func(r chi.Router) { 39 + r.Use(x.ServiceAuth.VerifyServiceAuth) 40 + 41 + r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 42 + r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 43 + r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 44 + }) 45 + 46 + // service query endpoints (no auth required) 47 + r.Get("/"+tangled.OwnerNSID, x.Owner) 41 48 42 49 return r 43 50 }
+7 -5
types/repo.go
··· 41 41 } 42 42 43 43 type RepoTreeResponse struct { 44 - Ref string `json:"ref,omitempty"` 45 - Parent string `json:"parent,omitempty"` 46 - Description string `json:"description,omitempty"` 47 - DotDot string `json:"dotdot,omitempty"` 48 - Files []NiceTree `json:"files,omitempty"` 44 + Ref string `json:"ref,omitempty"` 45 + Parent string `json:"parent,omitempty"` 46 + Description string `json:"description,omitempty"` 47 + DotDot string `json:"dotdot,omitempty"` 48 + Files []NiceTree `json:"files,omitempty"` 49 + ReadmeFileName string `json:"readme_filename,omitempty"` 50 + Readme string `json:"readme_contents,omitempty"` 49 51 } 50 52 51 53 type TagReference struct {
+1 -1
workflow/compile.go
··· 4 4 "errors" 5 5 "fmt" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 7 + "tangled.org/core/api/tangled" 8 8 ) 9 9 10 10 type RawWorkflow struct {
+1 -1
workflow/compile_test.go
··· 5 5 "testing" 6 6 7 7 "github.com/stretchr/testify/assert" 8 - "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.org/core/api/tangled" 9 9 ) 10 10 11 11 var trigger = tangled.Pipeline_TriggerMetadata{
+1 -1
workflow/def.go
··· 6 6 "slices" 7 7 "strings" 8 8 9 - "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.org/core/api/tangled" 10 10 11 11 "github.com/go-git/go-git/v5/plumbing" 12 12 "gopkg.in/yaml.v3"
+15
xrpc/errors/errors.go
··· 51 51 WithMessage("actor DID not supplied"), 52 52 ) 53 53 54 + var OwnerNotFoundError = NewXrpcError( 55 + WithTag("OwnerNotFound"), 56 + WithMessage("owner not set for this service"), 57 + ) 58 + 59 + var RepoNotFoundError = NewXrpcError( 60 + WithTag("RepoNotFound"), 61 + WithMessage("failed to access repository"), 62 + ) 63 + 64 + var RefNotFoundError = NewXrpcError( 65 + WithTag("RefNotFound"), 66 + WithMessage("failed to access ref"), 67 + ) 68 + 54 69 var AuthError = func(err error) XrpcError { 55 70 return NewXrpcError( 56 71 WithTag("Auth"),
+2 -2
xrpc/serviceauth/service_auth.go
··· 8 8 "strings" 9 9 10 10 "github.com/bluesky-social/indigo/atproto/auth" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/idresolver" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 const ActorDid string = "ActorDid"