1
0
Fork 0
timelinize/tlzapp/handlers.go
Matthew Holt 765a7f0780 Improve hidden file management (close #134)
And minor fix to regression in KML recognition
2025-10-11 16:33:49 -06:00

512 lines
16 KiB
Go

/*
Timelinize
Copyright (c) 2013 Matthew Holt
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package tlzapp
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"github.com/gorilla/websocket"
"github.com/timelinize/timelinize/timeline"
"go.uber.org/zap"
)
func (s *server) handleFileSelectorRoots(w http.ResponseWriter, _ *http.Request) error {
results, err := s.app.fileSelectorRoots()
return jsonResponse(w, results, err)
}
func (s *server) handleRepositoryEmpty(w http.ResponseWriter, r *http.Request) error {
repoID := r.Context().Value(ctxKeyPayload).(*string)
empty, err := s.app.RepositoryIsEmpty(*repoID)
return jsonResponse(w, empty, err)
}
func (s *server) handleItemClassifications(w http.ResponseWriter, r *http.Request) error {
repoID := r.Context().Value(ctxKeyPayload).(*string)
empty, err := s.app.ItemClassifications(*repoID)
return jsonResponse(w, empty, err)
}
type addEntityPayload struct {
RepoID string `json:"repo_id"`
Entity timeline.Entity `json:"entity"`
}
func (s *server) handleAddEntity(w http.ResponseWriter, r *http.Request) error {
payload := r.Context().Value(ctxKeyPayload).(*addEntityPayload)
return jsonResponse(w, nil, s.app.AddEntity(payload.RepoID, payload.Entity))
}
type getEntityPayload struct {
RepoID string `json:"repo_id"`
EntityID uint64 `json:"entity_id"`
}
func (s *server) handleGetEntity(w http.ResponseWriter, r *http.Request) error {
payload := r.Context().Value(ctxKeyPayload).(*getEntityPayload)
entity, err := s.app.GetEntity(payload.RepoID, payload.EntityID)
return jsonResponse(w, entity, err)
}
type mergeEntitiesPayload struct {
RepoID string `json:"repo_id"`
BaseEntityID uint64 `json:"base_entity_id"`
OtherEntityIDs []uint64 `json:"other_entity_ids"`
}
func (s *server) handleMergeEntities(w http.ResponseWriter, r *http.Request) error {
payload := r.Context().Value(ctxKeyPayload).(*mergeEntitiesPayload)
err := s.app.MergeEntities(payload.RepoID, payload.BaseEntityID, payload.OtherEntityIDs)
return jsonResponse(w, nil, err)
}
func (s *server) handleCharts(w http.ResponseWriter, r *http.Request) error {
chartName, repoID := r.FormValue("name"), r.FormValue("repo_id")
q := r.URL.Query()
q.Del("name")
q.Del("repo_id")
r.URL.RawQuery = q.Encode()
stats, err := s.app.ChartStats(r.Context(), chartName, repoID, r.URL.Query())
return jsonResponse(w, stats, err)
}
type jobsPayload struct {
RepoID string `json:"repo_id"`
JobIDs []uint64 `json:"job_ids"`
MostRecent int `json:"most_recent"`
}
func (s *server) handleJobs(w http.ResponseWriter, r *http.Request) error {
payload := r.Context().Value(ctxKeyPayload).(*jobsPayload)
jobs, err := s.app.Jobs(payload.RepoID, payload.JobIDs, payload.MostRecent)
return jsonResponse(w, jobs, err)
}
func (s *server) handleCancelJobs(w http.ResponseWriter, r *http.Request) error {
payload := r.Context().Value(ctxKeyPayload).(*jobsPayload)
var firstErr error
for _, jobID := range payload.JobIDs {
err := s.app.CancelJob(r.Context(), payload.RepoID, jobID)
if err != nil {
s.log.Error("canceling job failed",
zap.Uint64("job_id", jobID),
zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
return jsonResponse(w, nil, firstErr)
}
type jobPayload struct {
RepoID string `json:"repo_id"`
JobID uint64 `json:"job_id"`
StartOver bool `json:"start_over,omitempty"` // only used with StartJob
}
func (s *server) handlePauseJob(w http.ResponseWriter, r *http.Request) error {
payload := r.Context().Value(ctxKeyPayload).(*jobPayload)
err := s.app.PauseJob(r.Context(), payload.RepoID, payload.JobID)
return jsonResponse(w, nil, err)
}
func (s *server) handleUnpauseJob(w http.ResponseWriter, r *http.Request) error {
payload := r.Context().Value(ctxKeyPayload).(*jobPayload)
err := s.app.UnpauseJob(r.Context(), payload.RepoID, payload.JobID)
return jsonResponse(w, nil, err)
}
func (s *server) handleStartJob(w http.ResponseWriter, r *http.Request) error {
payload := r.Context().Value(ctxKeyPayload).(*jobPayload)
err := s.app.StartJob(r.Context(), payload.RepoID, payload.JobID, payload.StartOver)
return jsonResponse(w, nil, err)
}
func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) error {
allSettings, err := s.app.GetSettings(r.Context())
if allSettings.Application != nil {
allSettings.Application.RLock()
defer allSettings.Application.RUnlock()
}
return jsonResponse(w, allSettings, err)
}
type changeSettingsPayload struct {
Application map[string]json.RawMessage `json:"application"`
Timelines map[string]map[string]any `json:"timelines,omitempty"` // map of repo ID to map of property keys to values
}
// TODO: I guess, "get settings" could just be this handler with an empty payload. *shrug*
func (s *server) handleChangeSettings(w http.ResponseWriter, r *http.Request) error {
payload := r.Context().Value(ctxKeyPayload).(*changeSettingsPayload)
err := s.app.ChangeSettings(r.Context(), payload)
if err != nil {
return jsonResponse(w, nil, err)
}
return s.handleSettings(w, r)
}
func (s *server) handleFileStat(w http.ResponseWriter, r *http.Request) error {
filename := r.Context().Value(ctxKeyPayload).(*string)
info, err := os.Stat(*filename)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, fs.ErrNotExist) {
status = http.StatusNotFound
}
return Error{
Err: err,
HTTPStatus: status,
Log: "stat'ing a file",
Message: "Had trouble getting info about that file.",
}
}
var fullName string
if abs, err := filepath.Abs(*filename); err == nil {
fullName = abs
}
result := localFile{
FullName: fullName,
Name: info.Name(),
Size: info.Size(),
Mode: info.Mode(),
ModTime: info.ModTime(),
IsDir: info.IsDir(),
}
return jsonResponse(w, result, nil)
}
func (server) handleLogs(w http.ResponseWriter, r *http.Request) error {
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return Error{
Err: err,
HTTPStatus: http.StatusBadRequest,
Log: "upgrading request to websocket",
Message: "This endpoint expects a WebSocket client.",
}
}
defer conn.Close()
// while the client is connected, broadcast the logs to it
timeline.AddLogConn(conn)
defer timeline.RemoveLogConn(conn)
// simply keep the connection open until the client closes it
for {
_, _, err = conn.ReadMessage()
if err != nil {
break
}
}
return nil
}
func (s *server) handleRepos(w http.ResponseWriter, _ *http.Request) error {
return jsonResponse(w, s.app.getOpenRepositories(), nil)
}
func (s *server) handleBuildInfo(w http.ResponseWriter, _ *http.Request) error {
return jsonResponse(w, s.app.BuildInfo(), nil)
}
func (s *server) handleGetDataSources(w http.ResponseWriter, r *http.Request) error {
allDS, err := s.app.DataSources(r.Context(), "")
return jsonResponse(w, allDS, err)
}
type openRepoPayload struct {
RepoPath string `json:"repo_path"`
Create bool `json:"create"`
}
func (s *server) handleOpenRepo(w http.ResponseWriter, r *http.Request) error {
payload := r.Context().Value(ctxKeyPayload).(*openRepoPayload)
// TODO: maybe have the app methods return structured errors
openedTL, err := s.app.openRepository(r.Context(), payload.RepoPath, payload.Create)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return Error{
Err: err,
HTTPStatus: http.StatusNotFound,
Log: "repo folder does not exist",
Message: "No timeline found.",
Data: err,
}
}
return Error{
Err: err,
HTTPStatus: http.StatusBadRequest,
Log: "failure opening timeline",
Message: "Failed to open that timeline.",
Data: err,
}
}
return jsonResponse(w, openedTL, nil)
}
func (s *server) handleCloseRepo(w http.ResponseWriter, r *http.Request) error {
repoID := r.Context().Value(ctxKeyPayload).(*string)
return jsonResponse(w, nil, s.app.CloseRepository(*repoID))
}
func (s *server) handlePlanImport(w http.ResponseWriter, r *http.Request) error {
plannerOptions := *r.Context().Value(ctxKeyPayload).(*PlannerOptions)
importPlan, err := s.app.PlanImport(r.Context(), plannerOptions)
return jsonResponse(w, importPlan, err)
}
func (s *server) handleImport(w http.ResponseWriter, r *http.Request) error {
params := *r.Context().Value(ctxKeyPayload).(*ImportParameters)
jobID, err := s.app.Import(params)
return jsonResponse(w, map[string]any{"job_id": jobID}, err)
}
func (s *server) handleNextGraph(w http.ResponseWriter, r *http.Request) error {
repoID, jobIDStr := r.FormValue("repo_id"), r.FormValue("job_id")
jobID, err := strconv.ParseUint(jobIDStr, 10, 64)
if err != nil {
return jsonResponse(w, nil, fmt.Errorf("job ID must be an integer: %w", err))
}
graph, err := s.app.NextGraph(repoID, jobID)
return jsonResponse(w, graph, err)
}
type submitGraphPayload struct {
RepoID string `json:"repo_id"`
JobID uint64 `json:"job_id"`
Graph *timeline.Graph `json:"graph"`
Skip bool `json:"skip"`
}
func (s *server) handleSubmitGraph(w http.ResponseWriter, r *http.Request) error {
params := *r.Context().Value(ctxKeyPayload).(*submitGraphPayload)
err := s.app.SubmitGraph(params.RepoID, params.JobID, params.Graph, params.Skip)
return jsonResponse(w, nil, err)
}
func (s *server) handleSearchItems(w http.ResponseWriter, r *http.Request) error {
params := r.Context().Value(ctxKeyPayload).(*timeline.ItemSearchParams)
results, err := s.app.SearchItems(*params)
return jsonResponse(w, results, err)
}
func (s *server) handleSearchEntities(w http.ResponseWriter, r *http.Request) error {
params := r.Context().Value(ctxKeyPayload).(*timeline.EntitySearchParams)
results, err := s.app.SearchEntities(*params)
return jsonResponse(w, results, err)
}
func (s *server) handleRecentConversations(w http.ResponseWriter, r *http.Request) error {
params := r.Context().Value(ctxKeyPayload).(*timeline.ItemSearchParams)
results, err := s.app.LoadRecentConversations(r.Context(), *params)
return jsonResponse(w, results, err)
}
func (s *server) handleConversation(w http.ResponseWriter, r *http.Request) error {
params := r.Context().Value(ctxKeyPayload).(*timeline.ItemSearchParams)
results, err := s.app.LoadConversation(r.Context(), *params)
return jsonResponse(w, results, err)
}
type deleteItemsPayload struct {
RepoID string `json:"repo_id"`
ItemIDs []uint64 `json:"item_ids"`
timeline.DeleteOptions
}
func (s *server) handleDeleteItems(w http.ResponseWriter, r *http.Request) error {
payload := *r.Context().Value(ctxKeyPayload).(*deleteItemsPayload)
err := s.app.DeleteItems(payload.RepoID, payload.ItemIDs, payload.DeleteOptions)
return jsonResponse(w, nil, err)
}
// func (app) handleAutocompletePerson(w http.ResponseWriter, r *http.Request) error {
// var payload struct {
// Repo string `json:"repo"`
// Prefix string `json:"prefix"`
// }
// err := json.NewDecoder(r.Body).Decode(&payload)
// if err != nil {
// return jsonDecodeErr(err)
// }
// return nil
// }
type payloadFileListing struct {
Path string `json:"path"`
OnlyDirs bool `json:"only_dirs"`
ShowHidden bool `json:"show_hidden"`
Autocomplete bool `json:"autocomplete"`
}
func (server) handleFileListing(w http.ResponseWriter, r *http.Request) error {
listingReq := *r.Context().Value(ctxKeyPayload).(*payloadFileListing)
if listingReq.Path == "" {
listingReq.Path = userHomeDir()
} else if strings.HasPrefix(listingReq.Path, "~/") {
listingReq.Path = userHomeDir() + listingReq.Path[1:] // keep the leading "/", just trim the "~"
}
// for some reason, on Windows, requesting the file listing of "C:" shows
// the contents of C:\Windows\system32, but requesting "C:\" works fine; so
// let's go ahead and fix that, shall we?
if runtime.GOOS == osWindows && len(listingReq.Path) == 2 && listingReq.Path[1] == ':' {
listingReq.Path += `\`
}
// always work with absolute paths
absolutePath, err := filepath.Abs(listingReq.Path)
if err != nil {
return Error{
Err: err,
HTTPStatus: http.StatusBadRequest,
Log: "Computing absolute path",
}
}
// give appropriate HTTP status code for the situation
properError := func(err error) error {
status := http.StatusInternalServerError
if errors.Is(err, fs.ErrNotExist) {
status = http.StatusNotFound
} else if errors.Is(err, fs.ErrPermission) {
status = http.StatusForbidden
}
return Error{
Err: err,
HTTPStatus: status,
Log: "Accessing path",
Message: fmt.Sprintf("We couldn't access that file path (%s).", listingReq.Path),
Recommendations: []string{
"Make sure the file or folder exists.",
"Make sure permission is granted to access the file or folder.",
},
}
}
// if autocompleting, we'll set this with the remnant after the dir to help filter results
var filenamePrefix string
// get info about the path because we need to differentiate file from directory
info, err := os.Stat(listingReq.Path)
if errors.Is(err, fs.ErrNotExist) && listingReq.Autocomplete {
// with autocomplete enabled, this means the user is likely typing their path manually, so
// we can expect incomplete paths; try accessing the path's dir instead, and use whatever
// comes after the dir to filter the files in the dir, since that seems to be what the user
// is trying to get at
filenamePrefix = filepath.Base(listingReq.Path)
listingReq.Path = filepath.Dir(listingReq.Path)
info, err = os.Stat(listingReq.Path)
}
if err != nil {
return properError(err)
}
// prepare response: compute up-dir, and if a file was requested
// instead of a dir, list the dir but mark the file as selected
var result fileListing
if up := filepath.Join(absolutePath, ".."); up != absolutePath {
result.Up = up
}
if info.IsDir() {
result.Dir = listingReq.Path
} else {
result.Selected = filepath.Base(listingReq.Path)
result.Dir = filepath.Dir(listingReq.Path)
}
// open the directory
dir, err := os.Open(result.Dir)
if err != nil {
return properError(err)
}
defer dir.Close()
// get the directory listing (TODO: support pagination with this API...)
const maxEntries = 2000
fileInfos, err := dir.Readdir(maxEntries)
if err != nil && !errors.Is(err, io.EOF) {
return Error{
Err: err,
HTTPStatus: http.StatusBadRequest,
Log: "Reading directory",
Message: "Unable to list the contents of that directory.",
}
}
// convert the list of file infos into a list of files for the client
result.Files = make([]localFile, 0, len(fileInfos))
for _, info := range fileInfos {
name := info.Name()
// filter the listing
if filenamePrefix != "" && !strings.HasPrefix(name, filenamePrefix) {
continue
}
if listingReq.OnlyDirs && !info.IsDir() {
continue
}
if !listingReq.ShowHidden && fileHidden(filepath.Join(result.Dir, name)) {
continue
}
fullName := filepath.Join(result.Dir, name)
result.Files = append(result.Files, localFile{
FullName: fullName,
Name: name,
Size: info.Size(),
Mode: info.Mode(),
ModTime: info.ModTime(),
IsDir: info.IsDir(),
})
}
// sort alphabetically, with folders grouped at the top
sort.Slice(result.Files, func(i, j int) bool {
if result.Files[i].IsDir == result.Files[j].IsDir {
return strings.ToLower(result.Files[i].Name) < strings.ToLower(result.Files[j].Name)
}
return result.Files[i].IsDir
})
return jsonResponse(w, result, nil)
}
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(_ *http.Request) bool { return true }, // we check Origin earlier
}