Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.26.0

require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
golang.org/x/crypto v0.48.0
golang.org/x/sys v0.42.0
modernc.org/sqlite v1.52.0
non24.app/core v0.0.0
Expand All @@ -15,7 +16,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/text v0.34.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
Expand Down
6 changes: 4 additions & 2 deletions apps/server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,17 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
Expand Down
251 changes: 251 additions & 0 deletions apps/server/internal/api/portal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package api

import (
"errors"
"log"
"net/http"
"strings"
"time"

"non24.app/server/internal/portal"
"non24.app/server/internal/portalbridge"
syncmodel "non24.app/server/internal/sync"
)

// portalAdmin is the owner-authenticated half of sharing. It is registered
// only when the portal is configured, so a daemon with the portal off has no
// sharing routes at all.
type portalAdmin struct {
store *portal.Store
origin string
materializer portalbridge.Materializer
}

// WithPortal enables the share-link administration routes and wires
// materialization. The public /p/ mux is mounted separately by the daemon;
// this option governs only the owner surface.
func WithPortal(portalStore *portal.Store, publicOrigin string, materializer portalbridge.Materializer) Option {
return func(s *Server) {
s.portal = &portalAdmin{
store: portalStore,
origin: strings.TrimSuffix(publicOrigin, "/"),
materializer: materializer,
}
}
}

// sharingDisclosure is required by exposure-gate item 7. It states the
// measured uncertainty and the residual risk of live sharing in the same
// breath as the link, so an owner cannot create one without seeing both.
const sharingDisclosure = "Anyone with this link and passcode can see broad windows when you are likely awake, and can watch those windows change over time. " +
"The estimate is often off by about 2 hours and sometimes more. " +
"Screenshots and anything a recipient remembers cannot be revoked."

type createPortalProfileRequest struct {
Label string `json:"label"`
Passcode string `json:"passcode"`
ExpiresInDays int `json:"expiresInDays"`
Grants portal.Grants `json:"grants"`
}

type createPortalProfileResponse struct {
SchemaVersion string `json:"schema_version"`
ProfileID string `json:"profileId"`
LinkURL string `json:"linkUrl"`
ExpiresAt time.Time `json:"expiresAt"`
Disclosure string `json:"disclosure"`
}

type portalProfileDTO struct {
ProfileID string `json:"profileId"`
Label string `json:"label"`
State string `json:"state"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
Grants portal.Grants `json:"grants"`
Access []portalAccessCountDTO `json:"access"`
}

type portalAccessCountDTO struct {
Event string `json:"event"`
Count int `json:"count"`
LastAccess time.Time `json:"lastAccess"`
}

type portalProfileListResponse struct {
SchemaVersion string `json:"schema_version"`
Profiles []portalProfileDTO `json:"profiles"`
Disclosure string `json:"disclosure"`
}

func (s *Server) handleCreatePortalProfile(w http.ResponseWriter, r *http.Request) {
var req createPortalProfileRequest
if err := decodeBody(w, r, maxDeviceBodyBytes, &req); err != nil {
writeDecodeError(w, err)
return
}
if req.ExpiresInDays <= 0 {
writeError(w, http.StatusBadRequest, "expiresInDays must be positive")
return
}
now := s.now()
expiresAt := now.Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour)

profileID, err := portal.NewProfileID()
if err != nil {
writeError(w, http.StatusInternalServerError, "share profile creation failed")
return
}
token, err := portal.NewLinkToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "share profile creation failed")
return
}
if err := s.portal.store.CreateProfile(r.Context(), portal.CreateProfileInput{
ProfileID: profileID,
Token: token,
Passcode: req.Passcode,
Grants: req.Grants,
CreatedAt: now,
ExpiresAt: expiresAt,
}); err != nil {
// Validation failures here are the caller's fault (short passcode,
// over-long lifetime); they carry no secret and are safe to relay.
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.store.PutPortalLabel(r.Context(), profileID, req.Label, now.Format(time.RFC3339Nano)); err != nil {
writeError(w, http.StatusInternalServerError, "share profile creation failed")
return
}
// Materialize immediately so the recipient does not open a blank page.
if err := s.portal.materializer.MaterializeAll(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "share profile creation failed")
return
}

writeJSON(w, http.StatusCreated, createPortalProfileResponse{
SchemaVersion: syncmodel.SchemaVersion,
ProfileID: profileID,
LinkURL: s.portal.origin + "/p/" + token,
ExpiresAt: expiresAt,
Disclosure: sharingDisclosure,
})
}

func (s *Server) handleListPortalProfiles(w http.ResponseWriter, r *http.Request) {
now := s.now()
states, err := s.portal.store.ListProfiles(r.Context(), now)
if err != nil {
writeError(w, http.StatusInternalServerError, "share profile list failed")
return
}
labels, err := s.store.PortalLabels(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "share profile list failed")
return
}
since := now.Add(-30 * 24 * time.Hour)

profiles := make([]portalProfileDTO, 0, len(states))
for _, state := range states {
summaries, err := s.portal.store.SummarizeAccess(r.Context(), state.ID, since)
if err != nil {
writeError(w, http.StatusInternalServerError, "share profile list failed")
return
}
access := make([]portalAccessCountDTO, 0, len(summaries))
for _, summary := range summaries {
access = append(access, portalAccessCountDTO{
Event: string(summary.Event),
Count: summary.Count,
LastAccess: summary.LastAccess,
})
}
profiles = append(profiles, portalProfileDTO{
ProfileID: state.ID,
Label: labels[state.ID],
State: profileState(state),
CreatedAt: state.CreatedAt,
ExpiresAt: state.ExpiresAt,
Grants: state.Grants,
Access: access,
})
}
writeJSON(w, http.StatusOK, portalProfileListResponse{
SchemaVersion: syncmodel.SchemaVersion,
Profiles: profiles,
Disclosure: sharingDisclosure,
})
}

func profileState(state portal.ProfileState) string {
switch {
case state.Revoked:
return "revoked"
case state.Expired:
return "expired"
default:
return "active"
}
}

func (s *Server) handleRevokePortalProfile(w http.ResponseWriter, r *http.Request) {
profileID := strings.TrimSpace(r.PathValue("id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "share profile id is required")
return
}
if err := s.portal.store.RevokeProfile(r.Context(), profileID, s.now()); err != nil {
if errors.Is(err, portal.ErrProfileNotFound) {
writeError(w, http.StatusNotFound, "share profile not found")
return
}
writeError(w, http.StatusInternalServerError, "share profile revoke failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"schema_version": syncmodel.SchemaVersion,
"status": "revoked",
})
}

// handleErasePortalProfile removes the owner-side label and the portal-side
// access audit for a link. Revocation stops access; this erases the record
// that the link existed, matching the ADR-0014/0017 erasure right.
func (s *Server) handleErasePortalProfile(w http.ResponseWriter, r *http.Request) {
profileID := strings.TrimSpace(r.PathValue("id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "share profile id is required")
return
}
if err := s.portal.store.RevokeProfile(r.Context(), profileID, s.now()); err != nil && !errors.Is(err, portal.ErrProfileNotFound) {
writeError(w, http.StatusInternalServerError, "share profile erase failed")
return
}
if err := s.portal.store.EraseAudit(r.Context(), profileID); err != nil {
writeError(w, http.StatusInternalServerError, "share profile erase failed")
return
}
if err := s.store.DeletePortalLabel(r.Context(), profileID); err != nil {
writeError(w, http.StatusInternalServerError, "share profile erase failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"schema_version": syncmodel.SchemaVersion,
"status": "erased",
})
}

// refreshPortal republishes availability after private data changed. Failures
// are reported to the caller's log path rather than failing the sync request:
// a stale portal snapshot is a visible, honest state, but a rejected push
// would lose the device's data.
func (s *Server) refreshPortal(r *http.Request) {
if s.portal == nil {
return
}
if err := s.portal.materializer.MaterializeAll(r.Context()); err != nil {
log.Printf("portal: snapshot refresh failed: %v", err)
}
}
Loading