feat(swarm,interactions): Add federated interaction endpoints and update branding

- Add new swarm interaction endpoints for follow, unfollow, like, unlike, mention, and repost actions
- Create interactions.ts library module to handle federated interaction logic
- Add swarm post detail endpoint for retrieving individual post information
- Update post interaction routes to support federated operations
- Replace logo.svg with new logotext.svg for updated branding
- Update login page and sidebar components with new branding assets
- Enhance swarm discovery and type definitions to support new interaction patterns
- Update user follow endpoint to work with federated swarm interactions
This commit is contained in:
AskIt
2026-01-26 06:01:27 +01:00
parent 5e75482ec1
commit 2372f5c054
19 changed files with 1482 additions and 33 deletions
-1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 33 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 4532 847" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="logo.png" transform="matrix(1,0,0,1,64.677826,46)">
<path d="M304.019,263.551C305.652,271.447 303.736,283.853 305.372,289.534C309.02,302.195 300.103,295.787 299.567,295.402C295.892,292.761 179.642,227.694 173.743,224.084C172.18,223.128 154.443,213.343 150.751,211.07C140.744,204.906 140.277,205.902 130.411,199.662C126.035,196.894 124.425,196.538 118.768,193.053C118.171,192.685 75.592,168.79 67.72,164.118C50.371,153.821 40.078,151.747 48.297,148.155C51.477,146.766 58.668,141.301 72.373,134.267C73.58,133.647 82.469,127.438 96.37,120.261C98.188,119.322 104.017,114.775 120.378,106.272C121.461,105.709 121.004,105.086 133.418,98.339C146.666,91.138 146.145,90.391 163.368,81.274C165.176,80.317 164.94,79.941 185.4,68.304C206.053,56.556 205.664,55.998 226.403,44.323C242.236,35.409 253.536,28.4 255.86,26.958C258.106,25.565 258.573,26.419 284.583,10.654C294.55,4.612 295.136,4.631 295.44,5.542C296.789,9.575 294.774,9.868 296.624,15.486C297.168,17.14 295.72,46.107 297.419,50.524C298.475,53.27 296.609,76.802 298.486,81.504C299.397,83.786 297.688,108.545 299.404,113.529C300.21,115.871 298.88,136.601 300.425,141.522C302.579,148.383 298.499,148.681 301.636,170.486C302.136,173.958 300.811,195.308 302.477,199.507C303.468,202.002 301.591,224.981 303.43,229.522C304.452,232.045 302.979,232.185 304.019,263.551Z" style="fill:rgb(254,254,254);fill-opacity:0.99;"/>
<path d="M431.157,290.099C430.505,290.683 417.455,299.199 416.24,300.073C410.454,304.238 410.117,301.928 410.1,301.911C409.223,300.98 409.627,300.735 409.292,247.501C409.273,244.423 408.806,244.548 408.333,241.515C407.885,238.638 409.246,142.669 407.611,138.467C406.701,136.128 408.338,54.956 406.598,50.47C405.316,47.163 407.533,31.627 406.398,25.509C405.516,20.757 406.222,5.206 406.302,3.442C406.392,1.46 408.021,2.026 428.641,14.229C437.763,19.627 541.782,80.104 545.29,81.907C547.301,82.94 547.09,83.235 569.633,96.254C576.025,99.946 647.235,141.072 649.401,142.638C651.816,144.384 656.029,145.526 650.383,149.314C646.012,152.246 645.797,151.776 595.12,185.025C594.19,185.635 594.165,185.471 593.258,186.112C590.743,187.889 573.995,198.033 572.47,199.464C571.467,200.405 509.123,240.164 503.602,243.685C497.799,247.386 432.243,289.194 431.157,290.099Z" style="fill:rgb(254,254,254);fill-opacity:0.99;"/>
<path d="M33.196,269.863C35.094,271.32 53.244,280.215 58.604,284.36C59.591,285.123 60.061,284.284 72.29,291.86C72.708,292.12 78.887,295.201 86.291,299.853C89.193,301.676 94.674,304.083 102.284,308.859C110.072,313.747 110.497,312.868 118.277,317.868C119.996,318.974 120.342,318.299 140.627,330.255C145.794,333.3 189.693,358.096 191.285,358.909C195.409,361.014 215.67,373.434 230.262,380.952C231.907,381.799 248.026,390.104 250.133,392.776C252.561,395.854 247.834,394.016 208.151,418.039C206.78,418.869 206.719,418.711 190.606,427.709C174.878,436.491 170.409,438.814 168.63,439.738C167.644,440.251 167.798,440.488 156.591,446.686C61.751,499.128 62.031,499.508 53.615,503.733C52.269,504.409 33.347,515.25 22.665,520.796C19.384,522.499 6.684,530.214 5.305,531.051C-1.012,534.889 4.833,512.016 2.562,505.484C2.153,504.307 2.353,504.259 2.304,317.5C2.292,275.117 3.173,275.182 3.17,271.501C3.159,258.312 -0.553,250 7.247,254.933C10.234,256.821 32.436,269.163 33.196,269.863Z" style="fill:rgb(254,254,254);fill-opacity:0.99;"/>
<path d="M615.834,474.047C613.965,472.666 613.886,472.955 611.739,472.048C608.124,470.522 608.854,469.422 605.317,467.875C600.769,465.886 600.806,465.953 596.573,463.364C591.476,460.246 557.039,441.084 556.4,440.685C544.299,433.139 532.485,426.863 530.364,425.736C511.75,415.847 474.229,393.735 472.224,392.965C465.615,390.425 471.159,388.345 478.594,383.673C480.359,382.564 500.048,370.192 500.746,369.958C502.006,369.536 657.092,274.386 662.366,271.253C679.367,261.152 700.878,246.866 703.462,246.854C704.487,246.849 704.766,247.506 704.745,298.5C704.669,486.271 704.865,486.247 704.454,487.489C702.634,492.996 704.799,514.494 704.661,521.532C704.599,524.695 700.566,522.09 696.409,519.669C677.735,508.793 660.007,499.345 615.834,474.047Z" style="fill:rgb(254,254,254);fill-opacity:0.99;"/>
<path d="M304.007,677.487C303.299,704.156 305.311,704.237 302.808,715.521C301.909,719.575 304.513,755.179 300.568,755.816C299,756.069 223.586,711.063 217.58,708.352C215.199,707.277 202.655,699.526 201.372,698.733C190.449,691.984 176.482,684.739 172.388,681.657C170.844,680.495 170.424,681.416 151.379,669.722C141.349,663.562 70.832,623.138 70.589,622.456C70.344,621.765 69.857,620.391 77.43,616.361C87.379,611.065 112.843,595.213 122.372,590.262C123.516,589.668 123.193,589.134 136.343,582.226C137.425,581.658 137.047,581.069 149.385,574.278C152.149,572.756 151.869,572.356 183.378,554.281C185.591,553.011 206.973,540.745 210.741,537.896C211.596,537.25 211.701,537.437 222.393,531.297C224.61,530.023 240.546,520.87 249.874,514.976C258.239,509.689 277.147,499.627 307.685,480.9C311.561,478.524 309.72,487.101 309.682,493.501C309.614,505.156 310.12,505.167 309.175,510.411C308.706,513.019 309.341,513.039 307.903,543.51C307.181,558.821 308.884,573.611 306.792,582.517C306.468,583.899 307.061,615.423 306.469,617.491C305.179,621.995 305.598,659.749 304.007,677.487Z" style="fill:rgb(254,254,254);fill-opacity:0.99;"/>
<path d="M412.978,728.547C413.291,713.672 413.478,712.999 412.374,707.511C412.343,707.358 412.064,697.943 412.575,696.522C414.366,691.542 412.539,539.146 413.605,536.529C414.689,533.87 414.367,524.717 414.35,488.489C414.347,482.676 427.213,492.857 433.657,496.203C455.256,507.42 454.781,508.223 456.662,509.188C464.859,513.39 464.477,513.967 472.648,518.216C484.746,524.505 619.364,601.245 628.276,605.916C634.395,609.122 639.867,612.68 644.238,614.942C649.789,617.815 635.639,622.259 632.602,624.641C630.054,626.639 444.729,734.101 441.668,735.806C427.184,743.876 427.558,744.422 426.277,745.072C414.002,751.297 412.422,754.809 412.303,751.568C412.262,750.443 411.909,740.751 412.413,737.493C413.104,733.03 412.057,733.002 412.978,728.547Z" style="fill:rgb(254,254,254);fill-opacity:0.99;"/>
</g>
<g transform="matrix(1.383006,0,0,1.383006,-551.567585,-462.336524)">
<g transform="matrix(737.5,0,0,737.5,1085,854)">
<path d="M0.185,0.007C0.145,0.007 0.113,-0.006 0.088,-0.033C0.062,-0.06 0.05,-0.094 0.05,-0.137L0.05,-0.152C0.05,-0.159 0.053,-0.162 0.06,-0.162L0.077,-0.162C0.084,-0.162 0.087,-0.159 0.087,-0.152L0.087,-0.141C0.087,-0.107 0.096,-0.08 0.115,-0.059C0.133,-0.037 0.156,-0.027 0.184,-0.027C0.213,-0.027 0.236,-0.037 0.255,-0.057C0.274,-0.077 0.283,-0.103 0.283,-0.136C0.283,-0.157 0.278,-0.175 0.269,-0.191C0.259,-0.207 0.247,-0.22 0.234,-0.231C0.221,-0.242 0.197,-0.259 0.164,-0.284C0.137,-0.304 0.115,-0.321 0.1,-0.335C0.085,-0.349 0.072,-0.365 0.063,-0.384C0.054,-0.402 0.049,-0.423 0.049,-0.448C0.049,-0.49 0.061,-0.523 0.086,-0.547C0.11,-0.571 0.141,-0.583 0.18,-0.583C0.221,-0.583 0.254,-0.57 0.279,-0.543C0.304,-0.516 0.316,-0.481 0.316,-0.438L0.316,-0.42C0.316,-0.413 0.313,-0.41 0.306,-0.41L0.287,-0.41C0.28,-0.41 0.277,-0.413 0.277,-0.42L0.277,-0.436C0.277,-0.469 0.268,-0.496 0.25,-0.517C0.232,-0.538 0.209,-0.549 0.18,-0.549C0.153,-0.549 0.131,-0.54 0.114,-0.522C0.097,-0.504 0.088,-0.479 0.088,-0.446C0.088,-0.419 0.095,-0.397 0.111,-0.379C0.126,-0.361 0.153,-0.338 0.193,-0.31C0.227,-0.286 0.252,-0.267 0.269,-0.252C0.286,-0.237 0.299,-0.221 0.308,-0.204C0.317,-0.186 0.322,-0.165 0.322,-0.14C0.322,-0.096 0.309,-0.06 0.284,-0.034C0.258,-0.007 0.225,0.007 0.185,0.007Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(737.5,0,0,737.5,1073.2,854)">
</g>
<g transform="matrix(737.5,0,0,737.5,1420.5625,854)">
<path d="M0.167,-0C0.16,-0 0.157,-0.003 0.157,-0.01L0.157,-0.265C0.157,-0.268 0.157,-0.269 0.156,-0.27L0.038,-0.564L0.037,-0.568C0.037,-0.573 0.04,-0.576 0.046,-0.576L0.066,-0.576C0.071,-0.576 0.075,-0.573 0.077,-0.568L0.174,-0.316C0.175,-0.314 0.176,-0.313 0.177,-0.313C0.178,-0.313 0.179,-0.314 0.18,-0.316L0.276,-0.568C0.278,-0.573 0.282,-0.576 0.287,-0.576L0.307,-0.576C0.31,-0.576 0.313,-0.575 0.314,-0.573C0.315,-0.57 0.315,-0.567 0.314,-0.564L0.197,-0.27C0.196,-0.269 0.196,-0.268 0.196,-0.265L0.196,-0.01C0.196,-0.003 0.193,-0 0.186,-0L0.167,-0Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(737.5,0,0,737.5,1401.3875,854)">
</g>
<g transform="matrix(737.5,0,0,737.5,1740.6375,854)">
<path d="M0.31,-0.565C0.31,-0.572 0.313,-0.575 0.32,-0.575L0.339,-0.575C0.346,-0.575 0.349,-0.572 0.349,-0.565L0.349,-0.01C0.349,-0.003 0.346,-0 0.339,-0L0.318,-0C0.313,-0 0.309,-0.003 0.307,-0.008L0.114,-0.474C0.113,-0.475 0.112,-0.476 0.111,-0.476C0.11,-0.476 0.109,-0.475 0.109,-0.473L0.109,-0.01C0.109,-0.003 0.106,-0 0.099,-0L0.08,-0C0.073,-0 0.07,-0.003 0.07,-0.01L0.07,-0.565C0.07,-0.572 0.073,-0.575 0.08,-0.575L0.101,-0.575C0.106,-0.575 0.11,-0.572 0.112,-0.567L0.306,-0.099C0.307,-0.098 0.308,-0.097 0.309,-0.097C0.31,-0.097 0.311,-0.098 0.311,-0.1L0.31,-0.565Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(737.5,0,0,737.5,1740.6375,854)">
</g>
<g transform="matrix(737.5,0,0,737.5,2129.3,854)">
<path d="M0.316,-0C0.31,-0 0.306,-0.003 0.305,-0.009L0.281,-0.116C0.281,-0.117 0.281,-0.118 0.28,-0.119C0.279,-0.12 0.277,-0.12 0.276,-0.12L0.103,-0.12C0.102,-0.12 0.1,-0.12 0.1,-0.119C0.098,-0.118 0.098,-0.117 0.098,-0.116L0.074,-0.009C0.073,-0.003 0.07,-0 0.063,-0L0.044,-0C0.041,-0 0.038,-0.001 0.036,-0.003C0.035,-0.005 0.035,-0.008 0.036,-0.011L0.166,-0.567C0.167,-0.573 0.171,-0.576 0.177,-0.576L0.201,-0.576C0.207,-0.576 0.211,-0.573 0.212,-0.567L0.345,-0.011L0.346,-0.007C0.346,-0.002 0.343,-0 0.337,-0L0.316,-0ZM0.108,-0.157C0.107,-0.156 0.107,-0.155 0.108,-0.154C0.11,-0.153 0.111,-0.153 0.112,-0.153L0.268,-0.153C0.269,-0.153 0.271,-0.153 0.272,-0.154C0.273,-0.155 0.273,-0.156 0.272,-0.157L0.192,-0.508C0.191,-0.51 0.191,-0.511 0.19,-0.511C0.189,-0.511 0.189,-0.51 0.188,-0.508L0.108,-0.157Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(737.5,0,0,737.5,2129.3,854)">
</g>
<g transform="matrix(737.5,0,0,737.5,2489.2,854)">
<path d="M0.204,-0.578C0.243,-0.578 0.275,-0.564 0.3,-0.535C0.325,-0.506 0.337,-0.469 0.337,-0.422C0.337,-0.377 0.325,-0.34 0.3,-0.312C0.276,-0.284 0.244,-0.27 0.205,-0.27L0.108,-0.27C0.105,-0.27 0.104,-0.269 0.104,-0.266L0.104,-0.01C0.104,-0.003 0.101,-0 0.094,-0L0.075,-0C0.068,-0 0.065,-0.003 0.065,-0.01L0.065,-0.568C0.065,-0.575 0.068,-0.578 0.075,-0.578L0.204,-0.578ZM0.2,-0.303C0.229,-0.303 0.253,-0.314 0.271,-0.336C0.289,-0.357 0.298,-0.386 0.298,-0.422C0.298,-0.459 0.289,-0.488 0.271,-0.51C0.253,-0.532 0.229,-0.543 0.2,-0.543L0.108,-0.543C0.105,-0.543 0.104,-0.542 0.104,-0.539L0.104,-0.307C0.104,-0.304 0.105,-0.303 0.108,-0.303L0.2,-0.303Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(737.5,0,0,737.5,2489.2,854)">
</g>
<g transform="matrix(737.5,0,0,737.5,2840.9875,854)">
<path d="M0.185,0.007C0.145,0.007 0.113,-0.006 0.088,-0.033C0.062,-0.06 0.05,-0.094 0.05,-0.137L0.05,-0.152C0.05,-0.159 0.053,-0.162 0.06,-0.162L0.077,-0.162C0.084,-0.162 0.087,-0.159 0.087,-0.152L0.087,-0.141C0.087,-0.107 0.096,-0.08 0.115,-0.059C0.133,-0.037 0.156,-0.027 0.184,-0.027C0.213,-0.027 0.236,-0.037 0.255,-0.057C0.274,-0.077 0.283,-0.103 0.283,-0.136C0.283,-0.157 0.278,-0.175 0.269,-0.191C0.259,-0.207 0.247,-0.22 0.234,-0.231C0.221,-0.242 0.197,-0.259 0.164,-0.284C0.137,-0.304 0.115,-0.321 0.1,-0.335C0.085,-0.349 0.072,-0.365 0.063,-0.384C0.054,-0.402 0.049,-0.423 0.049,-0.448C0.049,-0.49 0.061,-0.523 0.086,-0.547C0.11,-0.571 0.141,-0.583 0.18,-0.583C0.221,-0.583 0.254,-0.57 0.279,-0.543C0.304,-0.516 0.316,-0.481 0.316,-0.438L0.316,-0.42C0.316,-0.413 0.313,-0.41 0.306,-0.41L0.287,-0.41C0.28,-0.41 0.277,-0.413 0.277,-0.42L0.277,-0.436C0.277,-0.469 0.268,-0.496 0.25,-0.517C0.232,-0.538 0.209,-0.549 0.18,-0.549C0.153,-0.549 0.131,-0.54 0.114,-0.522C0.097,-0.504 0.088,-0.479 0.088,-0.446C0.088,-0.419 0.095,-0.397 0.111,-0.379C0.126,-0.361 0.153,-0.338 0.193,-0.31C0.227,-0.286 0.252,-0.267 0.269,-0.252C0.286,-0.237 0.299,-0.221 0.308,-0.204C0.317,-0.186 0.322,-0.165 0.322,-0.14C0.322,-0.096 0.309,-0.06 0.284,-0.034C0.258,-0.007 0.225,0.007 0.185,0.007Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(737.5,0,0,737.5,2840.9875,854)">
</g>
<g transform="matrix(737.5,0,0,737.5,3188.35,854)">
<path d="M0.08,-0C0.073,-0 0.07,-0.003 0.07,-0.01L0.07,-0.565C0.07,-0.572 0.073,-0.575 0.08,-0.575L0.099,-0.575C0.106,-0.575 0.109,-0.572 0.109,-0.565L0.109,-0.01C0.109,-0.003 0.106,-0 0.099,-0L0.08,-0Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(737.5,0,0,737.5,3188.35,854)">
</g>
<g transform="matrix(737.5,0,0,737.5,3400.0125,854)">
<path d="M0.185,0.007C0.145,0.007 0.113,-0.006 0.088,-0.033C0.062,-0.06 0.05,-0.094 0.05,-0.137L0.05,-0.152C0.05,-0.159 0.053,-0.162 0.06,-0.162L0.077,-0.162C0.084,-0.162 0.087,-0.159 0.087,-0.152L0.087,-0.141C0.087,-0.107 0.096,-0.08 0.115,-0.059C0.133,-0.037 0.156,-0.027 0.184,-0.027C0.213,-0.027 0.236,-0.037 0.255,-0.057C0.274,-0.077 0.283,-0.103 0.283,-0.136C0.283,-0.157 0.278,-0.175 0.269,-0.191C0.259,-0.207 0.247,-0.22 0.234,-0.231C0.221,-0.242 0.197,-0.259 0.164,-0.284C0.137,-0.304 0.115,-0.321 0.1,-0.335C0.085,-0.349 0.072,-0.365 0.063,-0.384C0.054,-0.402 0.049,-0.423 0.049,-0.448C0.049,-0.49 0.061,-0.523 0.086,-0.547C0.11,-0.571 0.141,-0.583 0.18,-0.583C0.221,-0.583 0.254,-0.57 0.279,-0.543C0.304,-0.516 0.316,-0.481 0.316,-0.438L0.316,-0.42C0.316,-0.413 0.313,-0.41 0.306,-0.41L0.287,-0.41C0.28,-0.41 0.277,-0.413 0.277,-0.42L0.277,-0.436C0.277,-0.469 0.268,-0.496 0.25,-0.517C0.232,-0.538 0.209,-0.549 0.18,-0.549C0.153,-0.549 0.131,-0.54 0.114,-0.522C0.097,-0.504 0.088,-0.479 0.088,-0.446C0.088,-0.419 0.095,-0.397 0.111,-0.379C0.126,-0.361 0.153,-0.338 0.193,-0.31C0.227,-0.286 0.252,-0.267 0.269,-0.252C0.286,-0.237 0.299,-0.221 0.308,-0.204C0.317,-0.186 0.322,-0.165 0.322,-0.14C0.322,-0.096 0.309,-0.06 0.284,-0.034C0.258,-0.007 0.225,0.007 0.185,0.007Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(737.5,0,0,737.5,3400.0125,854)">
</g>
<g transform="matrix(737.5,0,0,737.5,3747.375,854)">
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

+94 -10
View File
@@ -2,14 +2,40 @@ import { NextResponse } from 'next/server';
import { db, posts, likes, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
import crypto from 'crypto';
type RouteContext = { params: Promise<{ id: string }> };
/**
* Extract domain from a swarm post ID (swarm:domain:postId)
*/
function extractSwarmDomain(apId: string | null): string | null {
if (!apId?.startsWith('swarm:')) return null;
const parts = apId.split(':');
return parts.length >= 2 ? parts[1] : null;
}
/**
* Check if a post is from a swarm node (has swarm: prefix in apId)
*/
function isSwarmPost(apId: string | null): boolean {
return apId?.startsWith('swarm:') ?? false;
}
/**
* Extract the original post ID from a swarm apId
*/
function extractSwarmPostId(apId: string): string | null {
const parts = apId.split(':');
return parts.length >= 3 ? parts[2] : null;
}
// Like a post
export async function POST(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: postId } = await context.params;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -59,15 +85,45 @@ export async function POST(request: Request, context: RouteContext) {
});
}
// Federate the like if the post has an ActivityPub ID
if (post.apId) {
// SWARM-FIRST: Check if this is a swarm post and deliver directly
if (isSwarmPost(post.apId)) {
const targetDomain = extractSwarmDomain(post.apId);
const originalPostId = extractSwarmPostId(post.apId!);
if (targetDomain && originalPostId) {
(async () => {
try {
const { deliverSwarmLike } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmLike(targetDomain, {
postId: originalPostId,
like: {
actorHandle: user.handle,
actorDisplayName: user.displayName || user.handle,
actorAvatarUrl: user.avatarUrl || undefined,
actorNodeDomain: nodeDomain,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (result.success) {
console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`);
} else {
console.warn(`[Swarm] Like delivery failed: ${result.error}`);
// Could fall back to ActivityPub here if needed
}
} catch (err) {
console.error('[Swarm] Error delivering like:', err);
}
})();
}
} else if (post.apId) {
// FALLBACK: Use ActivityPub for non-swarm posts
(async () => {
try {
const { createLikeActivity } = await import('@/lib/activitypub/activities');
const { deliverActivity } = await import('@/lib/activitypub/outbox');
const { fetchRemoteActor } = await import('@/lib/activitypub/fetch');
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Get the post author's actor URL
const postWithAuthor = await db.query.posts.findFirst({
@@ -78,11 +134,6 @@ export async function POST(request: Request, context: RouteContext) {
if (!postWithAuthor?.author) return;
const author = postWithAuthor.author as { handle: string };
// Construct the author's actor URL
const authorActorUrl = `https://${nodeDomain}/users/${author.handle}`;
// For remote posts, we'd need to fetch the remote actor's inbox
// For local posts, just log it since local delivery doesn't need federation
console.log(`[Federation] Like activity for post ${post.apId} from @${user.handle}`);
} catch (err) {
console.error('[Federation] Error federating like:', err);
@@ -104,6 +155,7 @@ export async function DELETE(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: postId } = await context.params;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -141,6 +193,38 @@ export async function DELETE(request: Request, context: RouteContext) {
.set({ likesCount: Math.max(0, post.likesCount - 1) })
.where(eq(posts.id, postId));
// SWARM-FIRST: Deliver unlike to swarm node
if (isSwarmPost(post.apId)) {
const targetDomain = extractSwarmDomain(post.apId);
const originalPostId = extractSwarmPostId(post.apId!);
if (targetDomain && originalPostId) {
(async () => {
try {
const { deliverSwarmUnlike } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmUnlike(targetDomain, {
postId: originalPostId,
unlike: {
actorHandle: user.handle,
actorNodeDomain: nodeDomain,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (result.success) {
console.log(`[Swarm] Unlike delivered to ${targetDomain}`);
} else {
console.warn(`[Swarm] Unlike delivery failed: ${result.error}`);
}
} catch (err) {
console.error('[Swarm] Error delivering unlike:', err);
}
})();
}
}
return NextResponse.json({ success: true, liked: false });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
+65 -7
View File
@@ -2,9 +2,34 @@ import { NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
import crypto from 'crypto';
type RouteContext = { params: Promise<{ id: string }> };
/**
* Extract domain from a swarm post ID (swarm:domain:postId)
*/
function extractSwarmDomain(apId: string | null): string | null {
if (!apId?.startsWith('swarm:')) return null;
const parts = apId.split(':');
return parts.length >= 2 ? parts[1] : null;
}
/**
* Check if a post is from a swarm node
*/
function isSwarmPost(apId: string | null): boolean {
return apId?.startsWith('swarm:') ?? false;
}
/**
* Extract the original post ID from a swarm apId
*/
function extractSwarmPostId(apId: string): string | null {
const parts = apId.split(':');
return parts.length >= 3 ? parts[2] : null;
}
// Repost a post
export async function POST(request: Request, context: RouteContext) {
try {
@@ -42,12 +67,13 @@ export async function POST(request: Request, context: RouteContext) {
}
// Create repost
const repostId = crypto.randomUUID();
const [repost] = await db.insert(posts).values({
userId: user.id,
content: '', // Reposts don't have their own content
repostOfId: postId,
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
apId: `https://${nodeDomain}/posts/${repostId}`,
apUrl: `https://${nodeDomain}/posts/${repostId}`,
}).returning();
// Update original post's repost count
@@ -69,9 +95,41 @@ export async function POST(request: Request, context: RouteContext) {
});
}
// Federate the repost (Announce activity) if the original post has an ActivityPub ID
const postApId = originalPost.apId;
if (postApId) {
// SWARM-FIRST: Deliver repost to swarm node
if (isSwarmPost(originalPost.apId)) {
const targetDomain = extractSwarmDomain(originalPost.apId);
const originalPostIdOnRemote = extractSwarmPostId(originalPost.apId!);
if (targetDomain && originalPostIdOnRemote) {
(async () => {
try {
const { deliverSwarmRepost } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmRepost(targetDomain, {
postId: originalPostIdOnRemote,
repost: {
actorHandle: user.handle,
actorDisplayName: user.displayName || user.handle,
actorAvatarUrl: user.avatarUrl || undefined,
actorNodeDomain: nodeDomain,
repostId: repost.id,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (result.success) {
console.log(`[Swarm] Repost delivered to ${targetDomain}`);
} else {
console.warn(`[Swarm] Repost delivery failed: ${result.error}`);
}
} catch (err) {
console.error('[Swarm] Error delivering repost:', err);
}
})();
}
} else if (originalPost.apId) {
// FALLBACK: Use ActivityPub for non-swarm posts
(async () => {
try {
const { createAnnounceActivity } = await import('@/lib/activitypub/activities');
@@ -82,7 +140,7 @@ export async function POST(request: Request, context: RouteContext) {
if (followerInboxes.length > 0) {
const announceActivity = createAnnounceActivity(
user,
postApId,
originalPost.apId!,
nodeDomain,
repost.id
);
@@ -91,7 +149,7 @@ export async function POST(request: Request, context: RouteContext) {
if (privateKey) {
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
const result = await deliverToFollowers(announceActivity, followerInboxes, privateKey, keyId);
console.log(`[Federation] Announce for ${postApId} delivered to ${result.delivered}/${followerInboxes.length} inboxes`);
console.log(`[Federation] Announce for ${originalPost.apId} delivered to ${result.delivered}/${followerInboxes.length} inboxes`);
}
}
} catch (err) {
+24
View File
@@ -134,6 +134,30 @@ export async function POST(request: Request) {
})();
}
// SWARM-FIRST: Deliver mentions to swarm nodes
(async () => {
try {
const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmMentions(
data.content,
post.id,
{
handle: user.handle,
displayName: user.displayName || user.handle,
avatarUrl: user.avatarUrl || undefined,
nodeDomain,
}
);
if (result.delivered > 0) {
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
}
} catch (err) {
console.error('[Swarm] Error delivering mentions:', err);
}
})();
// Federate the post to remote followers (non-blocking)
(async () => {
try {
@@ -0,0 +1,126 @@
/**
* Swarm Follow Endpoint
*
* POST: Receive a follow from another swarm node
*
* This enables swarm-native follows between Synapsis nodes,
* bypassing ActivityPub for faster, more direct connections.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, notifications, remoteFollowers } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
const swarmFollowSchema = z.object({
targetHandle: z.string(),
follow: z.object({
followerHandle: z.string(),
followerDisplayName: z.string(),
followerAvatarUrl: z.string().optional(),
followerBio: z.string().optional(),
followerNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/follow
*
* Receives a follow from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmFollowSchema.parse(body);
// Find the target user (local user being followed)
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, data.targetHandle.toLowerCase()),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (targetUser.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Construct the remote follower's actor URL (swarm-style)
const remoteHandle = `${data.follow.followerHandle}@${data.follow.followerNodeDomain}`;
const actorUrl = `swarm://${data.follow.followerNodeDomain}/${data.follow.followerHandle}`;
const inboxUrl = `https://${data.follow.followerNodeDomain}/api/swarm/interactions/inbox`;
// Check if this follow already exists
const existingFollow = await db.query.remoteFollowers.findFirst({
where: and(
eq(remoteFollowers.userId, targetUser.id),
eq(remoteFollowers.actorUrl, actorUrl)
),
});
if (existingFollow) {
return NextResponse.json({
success: true,
message: 'Already following',
});
}
// Create the remote follower record
await db.insert(remoteFollowers).values({
userId: targetUser.id,
actorUrl,
inboxUrl,
handle: remoteHandle,
activityId: data.follow.interactionId,
});
// Update follower count
await db.update(users)
.set({ followersCount: targetUser.followersCount + 1 })
.where(eq(users.id, targetUser.id));
// Get or create placeholder user for the remote follower (for notifications)
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
});
if (!remoteUser) {
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.follow.followerNodeDomain}:${data.follow.followerHandle}`,
handle: remoteHandle,
displayName: data.follow.followerDisplayName,
avatarUrl: data.follow.followerAvatarUrl || null,
bio: data.follow.followerBio || null,
publicKey: 'swarm-remote-user',
}).returning();
remoteUser = newUser;
}
// Create notification
await db.insert(notifications).values({
userId: targetUser.id,
actorId: remoteUser.id,
type: 'follow',
});
console.log(`[Swarm] Received follow from ${remoteHandle} for @${data.targetHandle}`);
return NextResponse.json({
success: true,
message: 'Follow received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Follow error:', error);
return NextResponse.json({ error: 'Failed to process follow' }, { status: 500 });
}
}
@@ -0,0 +1,100 @@
/**
* Swarm Like Endpoint
*
* POST: Receive a like from another swarm node
*
* This is the swarm-first approach - direct node-to-node communication
* for likes, bypassing ActivityPub for Synapsis nodes.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const swarmLikeSchema = z.object({
postId: z.string().uuid(),
like: z.object({
actorHandle: z.string(),
actorDisplayName: z.string(),
actorAvatarUrl: z.string().optional(),
actorNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/like
*
* Receives a like from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmLikeSchema.parse(body);
// Find the target post
const post = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
with: { author: true },
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
if (post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Increment like count
await db.update(posts)
.set({ likesCount: post.likesCount + 1 })
.where(eq(posts.id, data.postId));
// Create a notification for the post author
// First, get or create a placeholder user for the remote liker
const remoteHandle = `${data.like.actorHandle}@${data.like.actorNodeDomain}`;
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
});
if (!remoteUser) {
// Create a placeholder user for the remote actor
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.like.actorNodeDomain}:${data.like.actorHandle}`,
handle: remoteHandle,
displayName: data.like.actorDisplayName,
avatarUrl: data.like.actorAvatarUrl || null,
publicKey: 'swarm-remote-user',
}).returning();
remoteUser = newUser;
}
// Create notification
await db.insert(notifications).values({
userId: post.userId,
actorId: remoteUser.id,
postId: data.postId,
type: 'like',
});
console.log(`[Swarm] Received like from ${remoteHandle} on post ${data.postId}`);
return NextResponse.json({
success: true,
message: 'Like received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Like error:', error);
return NextResponse.json({ error: 'Failed to process like' }, { status: 500 });
}
}
@@ -0,0 +1,110 @@
/**
* Swarm Mention Endpoint
*
* POST: Receive a mention notification from another swarm node
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, notifications, posts } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const swarmMentionSchema = z.object({
mentionedHandle: z.string(),
mention: z.object({
actorHandle: z.string(),
actorDisplayName: z.string(),
actorAvatarUrl: z.string().optional(),
actorNodeDomain: z.string(),
postId: z.string(),
postContent: z.string(),
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/mention
*
* Receives a mention notification from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmMentionSchema.parse(body);
// Find the mentioned user (local user)
const mentionedUser = await db.query.users.findFirst({
where: eq(users.handle, data.mentionedHandle.toLowerCase()),
});
if (!mentionedUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (mentionedUser.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Get or create placeholder user for the remote actor
const remoteHandle = `${data.mention.actorHandle}@${data.mention.actorNodeDomain}`;
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
});
if (!remoteUser) {
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.mention.actorNodeDomain}:${data.mention.actorHandle}`,
handle: remoteHandle,
displayName: data.mention.actorDisplayName,
avatarUrl: data.mention.actorAvatarUrl || null,
publicKey: 'swarm-remote-user',
}).returning();
remoteUser = newUser;
}
// Check if we already have this post cached (from swarm timeline)
// If not, create a placeholder post for the notification
const swarmPostId = `swarm:${data.mention.actorNodeDomain}:${data.mention.postId}`;
let post = await db.query.posts.findFirst({
where: eq(posts.apId, swarmPostId),
});
if (!post) {
// Create a placeholder post for the mention
const [newPost] = await db.insert(posts).values({
userId: remoteUser.id,
content: data.mention.postContent,
apId: swarmPostId,
apUrl: `https://${data.mention.actorNodeDomain}/${data.mention.actorHandle}/posts/${data.mention.postId}`,
createdAt: new Date(data.mention.timestamp),
}).returning();
post = newPost;
}
// Create notification
await db.insert(notifications).values({
userId: mentionedUser.id,
actorId: remoteUser.id,
postId: post.id,
type: 'mention',
});
console.log(`[Swarm] Received mention from ${remoteHandle} for @${data.mentionedHandle}`);
return NextResponse.json({
success: true,
message: 'Mention received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Mention error:', error);
return NextResponse.json({ error: 'Failed to process mention' }, { status: 500 });
}
}
@@ -0,0 +1,95 @@
/**
* Swarm Repost Endpoint
*
* POST: Receive a repost from another swarm node
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const swarmRepostSchema = z.object({
postId: z.string().uuid(),
repost: z.object({
actorHandle: z.string(),
actorDisplayName: z.string(),
actorAvatarUrl: z.string().optional(),
actorNodeDomain: z.string(),
repostId: z.string(), // The ID of the repost on the actor's node
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/repost
*
* Receives a repost notification from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmRepostSchema.parse(body);
// Find the target post
const post = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
if (post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Increment repost count
await db.update(posts)
.set({ repostsCount: post.repostsCount + 1 })
.where(eq(posts.id, data.postId));
// Get or create placeholder user for the remote reposter
const remoteHandle = `${data.repost.actorHandle}@${data.repost.actorNodeDomain}`;
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
});
if (!remoteUser) {
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.repost.actorNodeDomain}:${data.repost.actorHandle}`,
handle: remoteHandle,
displayName: data.repost.actorDisplayName,
avatarUrl: data.repost.actorAvatarUrl || null,
publicKey: 'swarm-remote-user',
}).returning();
remoteUser = newUser;
}
// Create notification
await db.insert(notifications).values({
userId: post.userId,
actorId: remoteUser.id,
postId: data.postId,
type: 'repost',
});
console.log(`[Swarm] Received repost from ${remoteHandle} on post ${data.postId}`);
return NextResponse.json({
success: true,
message: 'Repost received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Repost error:', error);
return NextResponse.json({ error: 'Failed to process repost' }, { status: 500 });
}
}
@@ -0,0 +1,83 @@
/**
* Swarm Unfollow Endpoint
*
* POST: Receive an unfollow from another swarm node
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, remoteFollowers } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
const swarmUnfollowSchema = z.object({
targetHandle: z.string(),
unfollow: z.object({
followerHandle: z.string(),
followerNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/unfollow
*
* Receives an unfollow from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmUnfollowSchema.parse(body);
// Find the target user
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, data.targetHandle.toLowerCase()),
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Find and remove the remote follower record
const actorUrl = `swarm://${data.unfollow.followerNodeDomain}/${data.unfollow.followerHandle}`;
const existingFollow = await db.query.remoteFollowers.findFirst({
where: and(
eq(remoteFollowers.userId, targetUser.id),
eq(remoteFollowers.actorUrl, actorUrl)
),
});
if (!existingFollow) {
return NextResponse.json({
success: true,
message: 'Not following',
});
}
// Remove the follow
await db.delete(remoteFollowers).where(eq(remoteFollowers.id, existingFollow.id));
// Update follower count
await db.update(users)
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
.where(eq(users.id, targetUser.id));
console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`);
return NextResponse.json({
success: true,
message: 'Unfollow received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Unfollow error:', error);
return NextResponse.json({ error: 'Failed to process unfollow' }, { status: 500 });
}
}
@@ -0,0 +1,63 @@
/**
* Swarm Unlike Endpoint
*
* POST: Receive an unlike from another swarm node
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
const swarmUnlikeSchema = z.object({
postId: z.string().uuid(),
unlike: z.object({
actorHandle: z.string(),
actorNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
}),
});
/**
* POST /api/swarm/interactions/unlike
*
* Receives an unlike from another swarm node.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmUnlikeSchema.parse(body);
// Find the target post
const post = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Decrement like count (don't go below 0)
await db.update(posts)
.set({ likesCount: Math.max(0, post.likesCount - 1) })
.where(eq(posts.id, data.postId));
console.log(`[Swarm] Received unlike from ${data.unlike.actorHandle}@${data.unlike.actorNodeDomain} on post ${data.postId}`);
return NextResponse.json({
success: true,
message: 'Unlike received',
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Unlike error:', error);
return NextResponse.json({ error: 'Failed to process unlike' }, { status: 500 });
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Swarm Post Endpoint
*
* GET: Returns a single post for swarm requests
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media } from '@/db';
import { eq } from 'drizzle-orm';
type RouteContext = { params: Promise<{ id: string }> };
/**
* GET /api/swarm/posts/[id]
*
* Returns a single post with author info.
* Used by other nodes to fetch post details.
*/
export async function GET(request: NextRequest, context: RouteContext) {
try {
const { id: postId } = await context.params;
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Find the post with author
const post = await db.query.posts.findFirst({
where: eq(posts.id, postId),
with: { author: true },
});
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
if (post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Get media for the post
const postMedia = await db
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
.from(media)
.where(eq(media.postId, postId));
const author = post.author as {
handle: string;
displayName: string | null;
avatarUrl: string | null;
isNsfw: boolean;
};
return NextResponse.json({
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
author: {
handle: author.handle,
displayName: author.displayName || author.handle,
avatarUrl: author.avatarUrl || undefined,
isNsfw: author.isNsfw,
},
nodeDomain,
isNsfw: post.isNsfw || author.isNsfw,
likesCount: post.likesCount,
repostsCount: post.repostsCount,
repliesCount: post.repliesCount,
media: postMedia.length > 0 ? postMedia.map(m => ({
url: m.url,
mimeType: m.mimeType || undefined,
altText: m.altText || undefined,
})) : undefined,
linkPreviewUrl: post.linkPreviewUrl || undefined,
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
replyToId: post.replyToId || undefined,
repostOfId: post.repostOfId || undefined,
});
} catch (error) {
console.error('Swarm post error:', error);
return NextResponse.json(
{ error: 'Failed to fetch post' },
{ status: 500 }
);
}
}
+94 -10
View File
@@ -7,6 +7,7 @@ import { resolveRemoteUser } from '@/lib/activitypub/fetch';
import { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities';
import { deliverActivity } from '@/lib/activitypub/outbox';
import { cacheRemoteUserPosts } from '@/lib/activitypub/cache';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow } from '@/lib/swarm/interactions';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -94,23 +95,16 @@ export async function POST(request: Request, context: RouteContext) {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const remote = parseRemoteHandle(handle);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (currentUser.isSuspended || currentUser.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
if (remote) {
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox;
if (!targetInbox) {
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const targetHandle = `${remote.handle}@${remote.domain}`;
// Check if already following
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
@@ -121,6 +115,62 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Already following' }, { status: 400 });
}
// SWARM-FIRST: Check if this is a Synapsis swarm node
const isSwarm = await isSwarmNode(remote.domain);
if (isSwarm) {
// Use swarm protocol for Synapsis nodes
const activityId = crypto.randomUUID();
const result = await deliverSwarmFollow(remote.domain, {
targetHandle: remote.handle,
follow: {
followerHandle: currentUser.handle,
followerDisplayName: currentUser.displayName || currentUser.handle,
followerAvatarUrl: currentUser.avatarUrl || undefined,
followerBio: currentUser.bio || undefined,
followerNodeDomain: nodeDomain,
interactionId: activityId,
timestamp: new Date().toISOString(),
},
});
if (!result.success) {
console.warn(`[Swarm] Follow delivery failed, falling back to ActivityPub: ${result.error}`);
// Fall through to ActivityPub below
} else {
// Swarm follow succeeded - store the follow locally
await db.insert(remoteFollows).values({
followerId: currentUser.id,
targetHandle,
targetActorUrl: `swarm://${remote.domain}/${remote.handle}`,
inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`,
activityId,
displayName: null, // Will be fetched later
bio: null,
avatarUrl: null,
});
// Update the user's following count
await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 })
.where(eq(users.id, currentUser.id));
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
}
}
// FALLBACK: Use ActivityPub for non-swarm nodes or if swarm failed
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox;
if (!targetInbox) {
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
}
const activityId = crypto.randomUUID();
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
@@ -243,6 +293,7 @@ export async function DELETE(request: Request, context: RouteContext) {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const remote = parseRemoteHandle(handle);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (remote) {
if (!db) {
@@ -258,7 +309,40 @@ export async function DELETE(request: Request, context: RouteContext) {
if (!existingRemoteFollow) {
return NextResponse.json({ error: 'Not following' }, { status: 400 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// SWARM-FIRST: Check if this is a swarm follow (swarm:// actor URL)
const isSwarmFollow = existingRemoteFollow.targetActorUrl.startsWith('swarm://');
if (isSwarmFollow) {
// Use swarm protocol for unfollow
const result = await deliverSwarmUnfollow(remote.domain, {
targetHandle: remote.handle,
unfollow: {
followerHandle: currentUser.handle,
followerNodeDomain: nodeDomain,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (!result.success) {
console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`);
// Continue anyway - remove local record
}
// Remove the follow record
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
// Update the user's following count
await db.update(users)
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
.where(eq(users.id, currentUser.id));
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
return NextResponse.json({ success: true, following: false, remote: true, swarm: true });
}
// FALLBACK: Use ActivityPub for non-swarm follows
const originalFollow = createFollowActivity(
currentUser,
existingRemoteFollow.targetActorUrl,
+1 -1
View File
@@ -192,7 +192,7 @@ export default function LoginPage() {
/>
) : (
<Image
src="/logotext.png"
src="/logotext.svg"
alt="Synapsis"
width={200}
height={48}
+1 -1
View File
@@ -33,7 +33,7 @@ export function Sidebar() {
{customLogoUrl === undefined ? null : customLogoUrl ? (
<img src={customLogoUrl} alt="Logo" style={{ maxWidth: '200px', maxHeight: '50px', objectFit: 'contain' }} />
) : (
<Image src="/logotext.png" alt="Synapsis" width={185} height={42} priority />
<Image src="/logotext.svg" alt="Synapsis" width={185} height={42} priority />
)}
</Link>
<nav>
+1 -1
View File
@@ -45,7 +45,7 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
postCount = Number(postResult[0]?.count ?? 0);
}
const capabilities: SwarmCapability[] = ['handles', 'gossip'];
const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions'];
return {
domain,
+3
View File
@@ -7,14 +7,17 @@
* 1. Seed nodes (like node.synapsis.social) provide initial bootstrap
* 2. Gossip protocol spreads node/handle information epidemically
* 3. Any node can discover the full network without central authority
* 4. Direct node-to-node interactions (likes, follows, etc.) bypass ActivityPub
*
* Usage:
* - On node startup: call announceToSeeds() to register with the network
* - Periodically: call runGossipRound() to exchange info with peers
* - On demand: call discoverNode() to add a specific node
* - For interactions: use swarm-first delivery with AP fallback
*/
export * from './types';
export * from './registry';
export * from './discovery';
export * from './gossip';
export * from './interactions';
+474
View File
@@ -0,0 +1,474 @@
/**
* Swarm Interactions
*
* Handles direct node-to-node interactions in the swarm network.
* This is the "Swarm-first" approach - we try direct swarm communication
* first, and fall back to ActivityPub for non-Synapsis nodes.
*
* Supported interactions:
* - Likes: Direct like delivery between swarm nodes
* - Reposts: Direct repost/boost delivery
* - Follows: Swarm-native follow relationships
* - Replies: Already implemented in /api/swarm/replies
* - Mentions: Direct mention notifications
*/
import { getActiveSwarmNodes } from './registry';
import type { SwarmNodeInfo } from './types';
// ============================================
// TYPES
// ============================================
export interface SwarmInteraction {
type: 'like' | 'unlike' | 'repost' | 'unrepost' | 'follow' | 'unfollow' | 'mention';
// The actor performing the action
actor: {
handle: string;
displayName: string;
avatarUrl?: string;
nodeDomain: string;
};
// The target of the action
target: {
// For likes/reposts: the post ID and author
postId?: string;
postAuthorHandle?: string;
// For follows: the user being followed
userHandle?: string;
// For mentions: the mentioned user and post context
mentionedHandle?: string;
mentionPostId?: string;
mentionContent?: string;
};
// Metadata
timestamp: string;
interactionId: string; // Unique ID for deduplication
}
export interface SwarmInteractionResponse {
success: boolean;
message?: string;
error?: string;
}
export interface SwarmLikePayload {
postId: string;
like: {
actorHandle: string;
actorDisplayName: string;
actorAvatarUrl?: string;
actorNodeDomain: string;
interactionId: string;
timestamp: string;
};
}
export interface SwarmUnlikePayload {
postId: string;
unlike: {
actorHandle: string;
actorNodeDomain: string;
interactionId: string;
timestamp: string;
};
}
export interface SwarmRepostPayload {
postId: string;
repost: {
actorHandle: string;
actorDisplayName: string;
actorAvatarUrl?: string;
actorNodeDomain: string;
repostId: string; // The ID of the repost on the actor's node
interactionId: string;
timestamp: string;
};
}
export interface SwarmFollowPayload {
targetHandle: string;
follow: {
followerHandle: string;
followerDisplayName: string;
followerAvatarUrl?: string;
followerBio?: string;
followerNodeDomain: string;
interactionId: string;
timestamp: string;
};
}
export interface SwarmUnfollowPayload {
targetHandle: string;
unfollow: {
followerHandle: string;
followerNodeDomain: string;
interactionId: string;
timestamp: string;
};
}
export interface SwarmMentionPayload {
mentionedHandle: string;
mention: {
actorHandle: string;
actorDisplayName: string;
actorAvatarUrl?: string;
actorNodeDomain: string;
postId: string;
postContent: string;
interactionId: string;
timestamp: string;
};
}
// ============================================
// SWARM NODE DETECTION
// ============================================
/**
* Check if a domain is a known Synapsis swarm node
*/
export async function isSwarmNode(domain: string): Promise<boolean> {
const nodes = await getActiveSwarmNodes(500);
return nodes.some(n => n.domain === domain);
}
/**
* Get swarm node info if the domain is a swarm node
*/
export async function getSwarmNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
const nodes = await getActiveSwarmNodes(500);
return nodes.find(n => n.domain === domain) || null;
}
/**
* Extract domain from a handle (e.g., "user@node.example.com" -> "node.example.com")
*/
export function extractDomainFromHandle(handle: string): string | null {
const clean = handle.toLowerCase().replace(/^@/, '');
const parts = clean.split('@');
if (parts.length === 2) {
return parts[1];
}
return null;
}
/**
* Check if a handle belongs to a swarm node
*/
export async function isSwarmHandle(handle: string): Promise<boolean> {
const domain = extractDomainFromHandle(handle);
if (!domain) return false;
return isSwarmNode(domain);
}
// ============================================
// INTERACTION DELIVERY
// ============================================
/**
* Deliver a like to a swarm node
*/
export async function deliverSwarmLike(
targetDomain: string,
payload: SwarmLikePayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/like', payload);
}
/**
* Deliver an unlike to a swarm node
*/
export async function deliverSwarmUnlike(
targetDomain: string,
payload: SwarmUnlikePayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/unlike', payload);
}
/**
* Deliver a repost to a swarm node
*/
export async function deliverSwarmRepost(
targetDomain: string,
payload: SwarmRepostPayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/repost', payload);
}
/**
* Deliver a follow to a swarm node
*/
export async function deliverSwarmFollow(
targetDomain: string,
payload: SwarmFollowPayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/follow', payload);
}
/**
* Deliver an unfollow to a swarm node
*/
export async function deliverSwarmUnfollow(
targetDomain: string,
payload: SwarmUnfollowPayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/unfollow', payload);
}
/**
* Deliver a mention notification to a swarm node
*/
export async function deliverSwarmMention(
targetDomain: string,
payload: SwarmMentionPayload
): Promise<SwarmInteractionResponse> {
return deliverSwarmInteraction(targetDomain, '/api/swarm/interactions/mention', payload);
}
/**
* Generic interaction delivery
*/
async function deliverSwarmInteraction(
targetDomain: string,
endpoint: string,
payload: unknown
): Promise<SwarmInteractionResponse> {
try {
const baseUrl = targetDomain.startsWith('http')
? targetDomain
: targetDomain.startsWith('localhost') || targetDomain.startsWith('127.0.0.1')
? `http://${targetDomain}`
: `https://${targetDomain}`;
const url = `${baseUrl}${endpoint}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
return {
success: false,
error: `HTTP ${response.status}: ${errorText}`,
};
}
const data = await response.json();
return {
success: true,
message: data.message,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// ============================================
// PROFILE FETCHING
// ============================================
export interface SwarmUserProfile {
handle: string;
displayName: string;
bio?: string;
avatarUrl?: string;
headerUrl?: string;
website?: string;
followersCount: number;
followingCount: number;
postsCount: number;
createdAt: string;
isBot?: boolean;
nodeDomain: string;
}
export interface SwarmUserPost {
id: string;
content: string;
createdAt: string;
isNsfw: boolean;
likesCount: number;
repostsCount: number;
repliesCount: number;
media?: { url: string; mimeType?: string; altText?: string }[];
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
}
export interface SwarmProfileResponse {
profile: SwarmUserProfile;
posts: SwarmUserPost[];
nodeDomain: string;
timestamp: string;
}
/**
* Fetch a user profile from a swarm node
*/
export async function fetchSwarmUserProfile(
handle: string,
domain: string,
postsLimit: number = 25
): Promise<SwarmProfileResponse | null> {
try {
const baseUrl = domain.startsWith('http')
? domain
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
? `http://${domain}`
: `https://${domain}`;
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error(`[Swarm] Failed to fetch profile for ${handle}@${domain}:`, error);
return null;
}
}
/**
* Fetch a single post from a swarm node
*/
export async function fetchSwarmPost(
postId: string,
domain: string
): Promise<SwarmUserPost | null> {
try {
const baseUrl = domain.startsWith('http')
? domain
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
? `http://${domain}`
: `https://${domain}`;
const url = `${baseUrl}/api/swarm/posts/${postId}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error(`[Swarm] Failed to fetch post ${postId} from ${domain}:`, error);
return null;
}
}
// ============================================
// MENTION DETECTION & DELIVERY
// ============================================
/**
* Extract mentions from post content
* Returns array of { handle, domain } for remote mentions
*/
export function extractMentions(content: string): { handle: string; domain: string | null }[] {
// Match @handle or @handle@domain patterns
const mentionRegex = /@([a-zA-Z0-9_]+)(?:@([a-zA-Z0-9.-]+))?/g;
const mentions: { handle: string; domain: string | null }[] = [];
let match;
while ((match = mentionRegex.exec(content)) !== null) {
mentions.push({
handle: match[1].toLowerCase(),
domain: match[2]?.toLowerCase() || null,
});
}
return mentions;
}
/**
* Deliver mention notifications to swarm nodes
*/
export async function deliverSwarmMentions(
content: string,
postId: string,
actor: {
handle: string;
displayName: string;
avatarUrl?: string;
nodeDomain: string;
}
): Promise<{ delivered: number; failed: number }> {
const mentions = extractMentions(content);
let delivered = 0;
let failed = 0;
for (const mention of mentions) {
// Skip local mentions (no domain)
if (!mention.domain) continue;
// Check if it's a swarm node
const isSwarm = await isSwarmNode(mention.domain);
if (!isSwarm) continue;
// Deliver the mention
const result = await deliverSwarmMention(mention.domain, {
mentionedHandle: mention.handle,
mention: {
actorHandle: actor.handle,
actorDisplayName: actor.displayName,
actorAvatarUrl: actor.avatarUrl,
actorNodeDomain: actor.nodeDomain,
postId,
postContent: content,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (result.success) {
delivered++;
} else {
failed++;
}
}
return { delivered, failed };
}
+1 -1
View File
@@ -18,7 +18,7 @@ export interface SwarmNodeInfo {
lastSeenAt?: string;
}
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search';
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search' | 'interactions';
export interface SwarmAnnouncement {
domain: string;