Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/cmd/seed-daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"flag"
"os"
"path/filepath"
"slices"
"strings"
"time"
Expand Down Expand Up @@ -89,7 +90,12 @@ func main() {
if keyStoreEnvironment == "" {
keyStoreEnvironment = "main"
}
ks := core.NewOSKeyStore(keyStoreEnvironment)
var ks core.KeyStore
if os.Getenv("SEED_FILE_KEYSTORE") == "1" {
ks = core.NewFileKeyStore(filepath.Join(cfg.Base.DataDir, "keys.json"))
} else {
ks = core.NewOSKeyStore(keyStoreEnvironment)
}

dir, err := storage.Open(cfg.Base.DataDir, nil, ks, cfg.LogLevel)
if err != nil {
Expand Down
155 changes: 155 additions & 0 deletions backend/core/file_keystore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package core

import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
)

type fileKeyStore struct {
path string
mu sync.RWMutex
}

type fileKeyData struct {
Keys map[string][]byte `json:"keys"`
}

func NewFileKeyStore(path string) KeyStore {
return &fileKeyStore{path: path}
}

func (fks *fileKeyStore) load() (*fileKeyData, error) {
data, err := os.ReadFile(fks.path)
if err != nil {
if os.IsNotExist(err) {
return &fileKeyData{Keys: make(map[string][]byte)}, nil
}
return nil, err
}
var fkd fileKeyData
if err := json.Unmarshal(data, &fkd); err != nil {
return nil, err
}
if fkd.Keys == nil {
fkd.Keys = make(map[string][]byte)
}
return &fkd, nil
}

func (fks *fileKeyStore) save(fkd *fileKeyData) error {
data, err := json.MarshalIndent(fkd, "", " ")
if err != nil {
return err
}
return os.WriteFile(fks.path, data, 0600)
}

func (fks *fileKeyStore) GetKey(ctx context.Context, name string) (*KeyPair, error) {
fks.mu.RLock()
defer fks.mu.RUnlock()

fkd, err := fks.load()
if err != nil {
return nil, err
}

privBytes, ok := fkd.Keys[name]
if !ok {
return nil, fmt.Errorf("%s: %w", name, errKeyNotFound)
}

kp := new(KeyPair)
return kp, kp.UnmarshalBinary(privBytes)
}

func (fks *fileKeyStore) StoreKey(ctx context.Context, name string, kp *KeyPair) error {
if !nameFormat.MatchString(name) {
return fmt.Errorf("invalid name format")
}
if kp == nil {
return fmt.Errorf("can't store empty key")
}

fks.mu.Lock()
defer fks.mu.Unlock()

fkd, err := fks.load()
if err != nil {
return err
}

if _, ok := fkd.Keys[name]; ok {
return fmt.Errorf("Name already exists. Please delete it first")
}

keyBytes, err := kp.MarshalBinary()
if err != nil {
return err
}
fkd.Keys[name] = keyBytes
return fks.save(fkd)
}

func (fks *fileKeyStore) ListKeys(ctx context.Context) ([]NamedKey, error) {
fks.mu.RLock()
defer fks.mu.RUnlock()

fkd, err := fks.load()
if err != nil {
return nil, err
}

var ret []NamedKey
for name, privBytes := range fkd.Keys {
priv := new(KeyPair)
if err := priv.UnmarshalBinary(privBytes); err != nil {
return nil, err
}
ret = append(ret, NamedKey{Name: name, PublicKey: priv.Principal()})
}
return ret, nil
}

func (fks *fileKeyStore) DeleteKey(ctx context.Context, name string) error {
fks.mu.Lock()
defer fks.mu.Unlock()

fkd, err := fks.load()
if err != nil {
return err
}

if _, ok := fkd.Keys[name]; !ok {
return errKeyNotFound
}
delete(fkd.Keys, name)
return fks.save(fkd)
}

func (fks *fileKeyStore) DeleteAllKeys(ctx context.Context) error {
fks.mu.Lock()
defer fks.mu.Unlock()
return fks.save(&fileKeyData{Keys: make(map[string][]byte)})
}

func (fks *fileKeyStore) ChangeKeyName(ctx context.Context, currentName, newName string) error {
fks.mu.Lock()
defer fks.mu.Unlock()

fkd, err := fks.load()
if err != nil {
return err
}

privBytes, ok := fkd.Keys[currentName]
if !ok {
return errKeyNotFound
}

delete(fkd.Keys, currentName)
fkd.Keys[newName] = privBytes
return fks.save(fkd)
}
116 changes: 116 additions & 0 deletions frontend/apps/web/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from './instrumentation.server'
import {resolveResource} from './loaders'
import {logDebug} from './logger'
import {documentToMarkdown} from './markdown.server'
import {ParsedRequest, parseRequest} from './request'
import {
applyConfigSubscriptions,
Expand Down Expand Up @@ -263,6 +264,115 @@ function uriEncodedAuthors(authors: string[]) {
return authors.map((author) => encodeURIComponent(`hm://${author}`)).join(',')
}

/**
* Handle requests with .md extension - return raw markdown
* This enables bots and agents to easily consume SHM content without
* installing CLI tools or parsing HTML/React.
*
* Usage: GET https://hyper.media/hm/z6Mk.../path.md
* Returns: text/markdown with the document content
*/
async function handleMarkdownRequest(
parsedRequest: ParsedRequest,
hostname: string
): Promise<Response> {
const {url, pathParts} = parsedRequest

try {
// Strip .md extension from the last path part
const lastPart = pathParts[pathParts.length - 1]
const strippedPath = [...pathParts.slice(0, -1)]
if (lastPart && lastPart.endsWith('.md')) {
strippedPath.push(lastPart.slice(0, -3))
}

// Get service config to resolve account
const serviceConfig = await getConfig(hostname)
const originAccountId = serviceConfig?.registeredAccountUid

// Build the resource ID
let resourceId: ReturnType<typeof hmId> | null = null
const version = url.searchParams.get('v')
const latest = url.searchParams.get('l') === ''

if (strippedPath.length === 0) {
if (originAccountId) {
resourceId = hmId(originAccountId, {path: [], version, latest})
}
} else if (strippedPath[0] === 'hm') {
resourceId = hmId(strippedPath[1], {
path: strippedPath.slice(2),
version,
latest,
})
} else if (originAccountId) {
resourceId = hmId(originAccountId, {path: strippedPath, version, latest})
}

if (!resourceId) {
return new Response('# Not Found\n\nCould not resolve resource ID.', {
status: 404,
headers: {'Content-Type': 'text/markdown; charset=utf-8'},
})
}

// Fetch the resource
const resource = await resolveResource(resourceId)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this fn doing a discovery? I note that the .md pages are taking a really long time to resolve, and this whole thing should be nearly instant!


if (resource.type === 'document') {
const md = await documentToMarkdown(resource.document, {
includeMetadata: true,
includeFrontmatter: url.searchParams.has('frontmatter'),
})

return new Response(md, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'X-Hypermedia-Id': encodeURIComponent(resourceId.id),
'X-Hypermedia-Version': resource.document.version,
'X-Hypermedia-Type': 'Document',
'Cache-Control': 'public, max-age=60',
},
})
} else if (resource.type === 'comment') {
// For comments, create a simple markdown response
const content = resource.comment.content || []
const fakeDoc = {
content,
metadata: {},
version: resource.comment.version,
authors: [resource.comment.author],
} as any

const md = await documentToMarkdown(fakeDoc, {includeMetadata: false})

return new Response(md, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'X-Hypermedia-Id': encodeURIComponent(resourceId.id),
'X-Hypermedia-Type': 'Comment',
},
})
}

return new Response('# Not Found\n\nResource type not supported.', {
status: 404,
headers: {'Content-Type': 'text/markdown; charset=utf-8'},
})
} catch (e) {
console.error('Error handling markdown request:', e)
return new Response(
`# Error\n\nFailed to load resource: ${(e as Error).message}`,
{
status: 500,
headers: {'Content-Type': 'text/markdown; charset=utf-8'},
}
)
}
}

async function handleOptionsRequest(request: Request) {
const parsedRequest = parseRequest(request)
const {hostname} = parsedRequest
Expand Down Expand Up @@ -353,6 +463,12 @@ export default async function handleRequest(
status: 404,
})
}

// Handle .md extension requests - return raw markdown for bots/agents
if (url.pathname.endsWith('.md')) {
return await handleMarkdownRequest(parsedRequest, hostname)
}

if (url.pathname.startsWith('/hm/embed/')) {
// allowed to embed anywhere
} else {
Expand Down
Loading