added nextcloud
This commit is contained in:
+161
-135
@@ -1,152 +1,178 @@
|
|||||||
services:
|
services:
|
||||||
open-webui:
|
open-webui:
|
||||||
image: ghcr.io/open-webui/open-webui:main
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
container_name: open-webui
|
container_name: open-webui
|
||||||
ports:
|
ports:
|
||||||
- "4000:8080"
|
- "4000:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- open-webui:/app/backend/data
|
- open-webui:/app/backend/data
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- host.docker.internal:host-gateway
|
- host.docker.internal:host-gateway
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- tools
|
- tools
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
tools:
|
tools:
|
||||||
container_name: openwebui_tools
|
container_name: openwebui_tools
|
||||||
build:
|
build:
|
||||||
context: ./tools
|
context: ./tools
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
env_file: .env
|
env_file: .env
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:latest
|
image: postgres:latest
|
||||||
container_name: openwebui_postgres
|
container_name: openwebui_postgres
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=postgres
|
- POSTGRES_USER=postgres
|
||||||
- POSTGRES_PASSWORD=mypassword
|
- POSTGRES_PASSWORD=mypassword
|
||||||
- POSTGRES_DB=openwebui_db
|
- POSTGRES_DB=openwebui_db
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
# 8080
|
# 8080
|
||||||
searxng:
|
searxng:
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
container_name: searxng
|
container_name: searxng
|
||||||
volumes:
|
volumes:
|
||||||
- ./searxng.yml:/etc/searxng/settings.yml:ro,Z
|
- ./searxng.yml:/etc/searxng/settings.yml:ro,Z
|
||||||
- searxng_data:/etc/searxng:rw
|
- searxng_data:/etc/searxng:rw
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
# DELETEME: for local testing only (extern port closed)
|
# DELETEME: for local testing only (extern port closed)
|
||||||
ports:
|
ports:
|
||||||
- "4001:8080"
|
- "4001:8080"
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
coderunner:
|
coderunner:
|
||||||
build:
|
build:
|
||||||
context: ./coderunner
|
context: ./coderunner
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8787/openapi.json"]
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8787/openapi.json"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
group_add:
|
group_add:
|
||||||
- "977"
|
- "977"
|
||||||
|
|
||||||
# death
|
# death
|
||||||
environment:
|
environment:
|
||||||
DOCKER_HOST: "unix:///var/run/docker.sock"
|
DOCKER_HOST: "unix:///var/run/docker.sock"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:Z
|
- /var/run/docker.sock:/var/run/docker.sock:Z
|
||||||
# - ./tmp:/tmp
|
# - ./tmp:/tmp
|
||||||
|
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /run:rw,nosuid,nodev
|
- /run:rw,nosuid,nodev
|
||||||
- /tmp:rw,exec,nosuid,nodev,size=64m
|
- /tmp:rw,exec,nosuid,nodev,size=64m
|
||||||
|
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
- label=disable
|
- label=disable
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
browser:
|
browser:
|
||||||
build:
|
build:
|
||||||
context: ./browser
|
context: ./browser
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: browser
|
container_name: browser
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
# playwright/chromium has larger /dev/shm :D
|
# playwright/chromium has larger /dev/shm :D
|
||||||
shm_size: "1gb"
|
shm_size: "1gb"
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
environment:
|
environment:
|
||||||
WEBUI_IP: "0.0.0.0"
|
WEBUI_IP: "0.0.0.0"
|
||||||
WEBUI_PORT: "7788"
|
WEBUI_PORT: "7788"
|
||||||
ports:
|
ports:
|
||||||
- "7788:7788"
|
- "7788:7788"
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /opt/web-ui/tmp:rw,exec,nosuid,nodev,mode=1777,size=64m
|
- /opt/web-ui/tmp:rw,exec,nosuid,nodev,mode=1777,size=64m
|
||||||
volumes:
|
volumes:
|
||||||
- webui_data:/data
|
- webui_data:/data
|
||||||
# - webui_env:/opt/web-ui/.env
|
# - webui_env:/opt/web-ui/.env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7788').read()"]
|
test:
|
||||||
interval: 30s
|
[
|
||||||
timeout: 5s
|
"CMD",
|
||||||
retries: 5
|
"python",
|
||||||
restart: unless-stopped
|
"-c",
|
||||||
|
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:7788').read()",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
schedules-api:
|
schedules-api:
|
||||||
build: ./scheduler
|
build: ./scheduler
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "12253:12253"
|
- "12253:12253"
|
||||||
environment:
|
environment:
|
||||||
- PORT=12253
|
- PORT=12253
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
- TEMPLATES_FILE=/app/templates.json
|
- TEMPLATES_FILE=/app/templates.json
|
||||||
- DOCKER_SOCKET=/var/run/docker.sock
|
- DOCKER_SOCKET=/var/run/docker.sock
|
||||||
- TZ=America/New_York
|
- TZ=America/New_York
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
volumes:
|
volumes:
|
||||||
- schedule_data:/app/data
|
- schedule_data:/app/data
|
||||||
- ./templates.json:/app/templates.json:ro,Z
|
- ./templates.json:/app/templates.json:ro,Z
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
- ./tmp:/tmp
|
- ./tmp:/tmp
|
||||||
|
|
||||||
|
ollama-nextcloud:
|
||||||
|
build: ./nextcloud
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "13284:1111"
|
||||||
|
env_file: .env
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
volumes:
|
||||||
|
- ./tmp:/hosttmp
|
||||||
|
- nextcloud_data:/data/
|
||||||
|
|
||||||
|
# to store the files the model looks at (caching-ish)
|
||||||
|
- type: tmpfs
|
||||||
|
target: /tmp
|
||||||
|
tmpfs:
|
||||||
|
size: 1000m
|
||||||
|
# mode: 1777
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
open-webui:
|
open-webui:
|
||||||
pgdata:
|
pgdata:
|
||||||
searxng_data:
|
searxng_data:
|
||||||
webui_data:
|
webui_data:
|
||||||
schedule_data:
|
schedule_data:
|
||||||
|
nextcloud_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
npm-cache
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM oven/bun:1 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# prod deps
|
||||||
|
COPY package.json ./package.json
|
||||||
|
|
||||||
|
RUN bun install --ci --production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data && chown -R bun:bun /app/data
|
||||||
|
|
||||||
|
USER bun
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
CMD ["bun", "run", "server.ts"]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
type CacheRow = {
|
||||||
|
path_key: string,
|
||||||
|
fpath: string,
|
||||||
|
etag: string,
|
||||||
|
size: number,
|
||||||
|
mime: string | null,
|
||||||
|
cache_path: string,
|
||||||
|
updated_at: number
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: CHANGEME WHEN RUNNING IN DOCKER
|
||||||
|
export const CACHE_DIR = "/tmp",
|
||||||
|
db = new Database(path.resolve(`${CACHE_DIR}/index.sqlite`));
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
create table if not exists file_cache (
|
||||||
|
path_key text primary key,
|
||||||
|
fpath text not null,
|
||||||
|
etag text not null,
|
||||||
|
size integer not null,
|
||||||
|
mime text,
|
||||||
|
cache_path text not null,
|
||||||
|
updated_at integer not null
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const qGet = db.query<CacheRow, string>('select * from file_cache where path_key = ?;');
|
||||||
|
|
||||||
|
const qUpsert = db.query(`
|
||||||
|
insert into file_cache (path_key, fpath, etag, size, mime, cache_path, updated_at)
|
||||||
|
values (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
on conflict(path_key) do update set
|
||||||
|
etag=excluded.etag, size=excluded.size, mime=excluded.mime,
|
||||||
|
cache_path=excluded.cache_path, updated_at=excluded.updated_at;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const base64url = (buf: Buffer): string =>
|
||||||
|
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); // no padding
|
||||||
|
|
||||||
|
export const pathKey = (addr: string, fpath: string): string => {
|
||||||
|
// normalize to avoid duplicate keys for equivalent paths
|
||||||
|
const canonical = new URL(fpath, addr).toString(),
|
||||||
|
digest = createHash('sha256').update(canonical).digest();
|
||||||
|
|
||||||
|
return base64url(digest);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fanoutPath = (key: string): string =>
|
||||||
|
path.join(`${CACHE_DIR}/files`, key.slice(0, 2), key.slice(2, 4), key);
|
||||||
|
|
||||||
|
export async function ensureDir(p: string): Promise<void> {
|
||||||
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRow(key: string): CacheRow | undefined {
|
||||||
|
return qGet.get(key) as unknown as CacheRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertRow(row: CacheRow): void {
|
||||||
|
qUpsert.run(row.path_key, row.fpath, row.etag, row.size, row.mime, row.cache_path, row.updated_at);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Request } from "express";
|
||||||
|
import { WebDAVClient } from "webdav";
|
||||||
|
import { DIRS } from "./server";
|
||||||
|
|
||||||
|
|
||||||
|
export async function statOne(fpath: string, client: WebDAVClient) {
|
||||||
|
const parent = fpath.replace(/\/[^/]+$/, '') || '/',
|
||||||
|
base = fpath.split('/').filter(Boolean).pop(),
|
||||||
|
list = await client.getDirectoryContents(parent, { deep: false }),
|
||||||
|
entry = Array.isArray(list) ? list.find((e: any) => e.basename === base) : undefined;
|
||||||
|
|
||||||
|
if (!entry) throw `not found: ${fpath}`;
|
||||||
|
return { etag: entry.etag as string, size: Number(entry.size), mime: entry.mime as string | undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toRegExp = (spec: string): RegExp | null => {
|
||||||
|
// slash-delimited form: /pattern/flags
|
||||||
|
const m = spec.match(/^\/(.+)\/([a-z]*)$/i);
|
||||||
|
try {
|
||||||
|
if (m) {
|
||||||
|
const [, source, flags] = m;
|
||||||
|
return new RegExp(source, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw pattern form: "pattern"
|
||||||
|
return new RegExp(spec);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkIfHasPerms(req: Request) {
|
||||||
|
if (!req.body) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { fpath, obj_type, deep, usecache } = req.body,
|
||||||
|
o = { fpath, obj_type, deep, usecache };
|
||||||
|
|
||||||
|
if (!fpath) return false;
|
||||||
|
if (!DIRS) return o;
|
||||||
|
if (fpath.includes('..')) return false;
|
||||||
|
|
||||||
|
return DIRS.find(r => fpath.match(r)) ? o : false;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": {
|
||||||
|
"title": "Nextcloud Files API (openwebui-friendly)",
|
||||||
|
"description": "simple file/directory access via nextcloud webdav, with local disk cache.",
|
||||||
|
"version": "1.0.1"
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "http://ollama-nextcloud:1111",
|
||||||
|
"description": "local server (use absolute url for tool discovery)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/ping": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "nextcloudPing",
|
||||||
|
"summary": "health / verification probe",
|
||||||
|
"description": "simple json probe used by tool registrars to verify the server is reachable",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "ok",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"example": "2025-09-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/file": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "nextcloudGetFile",
|
||||||
|
"summary": "Fetch a file (proxied via WebDAV, cached locally)",
|
||||||
|
"description": "returns the raw file bytes. content-type mirrors the upstream mime when available; otherwise application/octet-stream. also supports an application/json metadata variant for tool registration and LLM-friendly responses.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fpath": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "absolute path in nextcloud webdav (e.g., /Documents/report.pdf)"
|
||||||
|
},
|
||||||
|
"bypasscache": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "if true, skip the local cache and fetch from upstream"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"fpath"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"default": {
|
||||||
|
"value": {
|
||||||
|
"fpath": "/Documents/report.pdf",
|
||||||
|
"bypasscache": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "file bytes or metadata",
|
||||||
|
"headers": {
|
||||||
|
"content-type": {
|
||||||
|
"description": "mime type from upstream or application/octet-stream",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FileMeta"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"example": {
|
||||||
|
"value": {
|
||||||
|
"filename": "/Documents/report.pdf",
|
||||||
|
"basename": "report.pdf",
|
||||||
|
"lastmod": "Mon, 01 Sep 2025 12:34:56 GMT",
|
||||||
|
"size": 2048,
|
||||||
|
"type": "file",
|
||||||
|
"etag": "\"a1b2c3d4e5\"",
|
||||||
|
"mime": "application/pdf",
|
||||||
|
"cached": true,
|
||||||
|
"download_url": "http://ollama-nextcloud:1111/file?fpath=/Documents/report.pdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/octet-stream": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"*/*": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/components/responses/NotFound"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"$ref": "#/components/responses/ServerError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "nextcloudUploadFile",
|
||||||
|
"summary": "Upload a file",
|
||||||
|
"description": "uploads a file into a target directory in nextcloud via webdav. requires a multipart/form-data body with the file part and the destination directory. the server will not overwrite existing files.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fdir": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "destination directory path (e.g., /Documents)"
|
||||||
|
},
|
||||||
|
"createnewdirs": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "if true and the directory does not exist, create it recursively before uploading"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary",
|
||||||
|
"description": "the file contents"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"fdir",
|
||||||
|
"file"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "upload succeeded",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"uploaded": {
|
||||||
|
"$ref": "#/components/schemas/DirEntry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"uploaded"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"example": "file uploaded successfully to https://nextcloud.example.com/remote.php/dav/files/user/Documents/example.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing file upload field",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "missing file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"example": "missing file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "file exists and overwrite is not permitted",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "file already exists, you do not have permissions to overwrite files"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"example": "file already exists, you do not have permissions to overwrite files"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"$ref": "#/components/responses/ServerError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/dir": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "nextcloudListDirectory",
|
||||||
|
"summary": "List a directory",
|
||||||
|
"description": "lists directory entries from nextcloud webdav. supports shallow or deep listing.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fpath": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "directory path in nextcloud webdav (e.g., /Documents)"
|
||||||
|
},
|
||||||
|
"deep": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "whether to recurse into subdirectories"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"fpath"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"default": {
|
||||||
|
"value": {
|
||||||
|
"fpath": "/Documents",
|
||||||
|
"deep": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "directory listing",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DirEntry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/components/responses/NotFound"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"$ref": "#/components/responses/ServerError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/openapi.json": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "nextcloudGetOpenapi",
|
||||||
|
"summary": "Serve OpenAPI schema",
|
||||||
|
"description": "serves this specification (used by open webui when registering a tool server).",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "openapi document",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"DirEntry": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "entry returned by webdav client",
|
||||||
|
"properties": {
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "/Documents/report.pdf"
|
||||||
|
},
|
||||||
|
"basename": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "report.pdf"
|
||||||
|
},
|
||||||
|
"lastmod": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Mon, 01 Sep 2025 12:34:56 GMT"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"example": 2048
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"file",
|
||||||
|
"directory"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"etag": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "\"a1b2c3d4e5\""
|
||||||
|
},
|
||||||
|
"mime": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"example": "application/pdf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"basename",
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"FileMeta": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "metadata about a file (json-friendly alternative to raw bytes)",
|
||||||
|
"properties": {
|
||||||
|
"filename": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"basename": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lastmod": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"file",
|
||||||
|
"directory"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"etag": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"mime": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"cached": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "whether the file was served from local cache"
|
||||||
|
},
|
||||||
|
"download_url": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"description": "url to download raw bytes (may be the same endpoint with different accept header)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"basename",
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"Unauthorized": {
|
||||||
|
"description": "not authorized to access the requested path (middleware denied or missing parameters)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NotFound": {
|
||||||
|
"description": "path not found upstream",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ServerError": {
|
||||||
|
"description": "unexpected server error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "nextcloud",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"webdav": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import * as multer from 'multer';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import fsSync from 'fs';
|
||||||
|
import { createClient } from 'webdav';
|
||||||
|
import { pathKey, fanoutPath, ensureDir, getRow, upsertRow, CACHE_DIR } from './cache.ts';
|
||||||
|
import { checkIfHasPerms, statOne, toRegExp } from './helpers.ts';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
type statRetType = {
|
||||||
|
"filename": string,
|
||||||
|
"basename": string,
|
||||||
|
"lastmod": string,
|
||||||
|
"size": number,
|
||||||
|
"type": "file" | "directory",
|
||||||
|
"etag": string,
|
||||||
|
"mime"?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
fobj?: {
|
||||||
|
fpath: any,
|
||||||
|
deep?: boolean,
|
||||||
|
bypasscache?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
NEXTCLOUD_APP_ID: APP_ID,
|
||||||
|
NEXTCLOUD_APP_PASS: APP_PASS,
|
||||||
|
NEXTCLOUD_WEBDAV_ADDR: ADDR,
|
||||||
|
NEXTCLOUD_ACCESS_DIRS: _DIRS,
|
||||||
|
PORT: PORT_RAW
|
||||||
|
} = process.env,
|
||||||
|
PORT = PORT_RAW || 1111;
|
||||||
|
|
||||||
|
if (!(ADDR && APP_ID && APP_PASS)) {
|
||||||
|
throw new Error("VARIABLES NOT FOUND IN ENV");
|
||||||
|
}
|
||||||
|
|
||||||
|
// const ADDR_URL = new URL(ADDR);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.text());
|
||||||
|
app.use((await import('cors')).default());
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path === '/openapi.json') {
|
||||||
|
return res.sendFile('openapi.json', { root: '.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET has no body (independant handlers)
|
||||||
|
else if (req.method === 'GET') return next();
|
||||||
|
|
||||||
|
const pth = checkIfHasPerms(req);
|
||||||
|
if (!pth && req.body?.fpath) {
|
||||||
|
return res.status(401).send(`Not allowed to access "${req.body.fpath}"`);
|
||||||
|
}
|
||||||
|
else if (!pth) {
|
||||||
|
return res.status(404).send("Unknown path");
|
||||||
|
}
|
||||||
|
|
||||||
|
req.fobj = pth;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
//@ts-ignore stupid
|
||||||
|
const multerInstance = multer.default({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: CACHE_DIR
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DIRS: string[] = (() => {
|
||||||
|
try {
|
||||||
|
if (!_DIRS) return null;
|
||||||
|
return JSON.parse(_DIRS).map(toRegExp)
|
||||||
|
}
|
||||||
|
catch (err) { console.error(err); }
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!DIRS) {
|
||||||
|
console.warn("NEXTCLOUD_ACCESS_DIRS not specified, tool has full access to all files");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient(process.env.NEXTCLOUD_WEBDAV_ADDR!, {
|
||||||
|
username: process.env.NEXTCLOUD_APP_ID!,
|
||||||
|
password: process.env.NEXTCLOUD_APP_PASS!,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/file', async (req, res) => {
|
||||||
|
if (!req.fobj) return res.sendStatus(401);
|
||||||
|
const { fpath, bypasscache } = req.fobj;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// read current etag/size from webdav
|
||||||
|
const { etag, size, mime } = await statOne(fpath, client).catch((err) => {
|
||||||
|
if (typeof err === 'string') {
|
||||||
|
console.error(err);
|
||||||
|
res.sendStatus(404).send(err);
|
||||||
|
return { etag: undefined, size: -1, mime: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!etag) return;
|
||||||
|
|
||||||
|
// lookup mapping
|
||||||
|
const key = pathKey(process.env.NEXTCLOUD_WEBDAV_ADDR!, fpath),
|
||||||
|
cache_path = fanoutPath(key),
|
||||||
|
row = bypasscache ? null : getRow(key);
|
||||||
|
|
||||||
|
// cached file
|
||||||
|
if (row && row.etag === etag) {
|
||||||
|
const data = await fs.readFile(row.cache_path);
|
||||||
|
res.setHeader('content-type', row.mime ?? 'application/octet-stream');
|
||||||
|
return res.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: If-None-Match here if client lib exposes raw headers
|
||||||
|
const buf = await client.getFileContents(fpath).catch(console.error);
|
||||||
|
if (!buf) return res.sendStatus(500);
|
||||||
|
|
||||||
|
await ensureDir(cache_path);
|
||||||
|
await fs.writeFile(cache_path, buf as any);
|
||||||
|
|
||||||
|
upsertRow({
|
||||||
|
path_key: key,
|
||||||
|
fpath,
|
||||||
|
etag,
|
||||||
|
size,
|
||||||
|
mime: mime ?? null,
|
||||||
|
cache_path,
|
||||||
|
updated_at: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('content-type', mime ?? 'application/octet-stream');
|
||||||
|
res.send(buf);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.sendStatus(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/dir', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.fobj) return res.sendStatus(401);
|
||||||
|
|
||||||
|
const { fpath, deep } = req.fobj,
|
||||||
|
filesInDir = await client.getDirectoryContents(fpath, {
|
||||||
|
deep
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(filesInDir);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/file', multerInstance.single('file'), async (req, res) => {
|
||||||
|
if (!req.fobj) return res.sendStatus(401);
|
||||||
|
if (!req.file) return res.status(400).send("missing file");
|
||||||
|
|
||||||
|
const { fdir, createnewdirs } = req.body as {
|
||||||
|
fdir: string,
|
||||||
|
createnewdirs: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createnewdirs && !(await client.exists(fdir))) {
|
||||||
|
await client.createDirectory(fdir, {
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fname = req.file.filename || req.file.originalname,
|
||||||
|
fpath = path.join(fdir, fname);
|
||||||
|
|
||||||
|
if (await client.exists(fpath)) {
|
||||||
|
return res.status(503).send('file already exists, you do not have permissions to overwrite files');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sstr = fsSync.createReadStream(req.file.path).pipe(client.createWriteStream(fpath, {
|
||||||
|
overwrite: false
|
||||||
|
}, (r) => {
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.send(`file uploaded successfully to ${r.url}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
sstr.on('error', (err) => {
|
||||||
|
console.error(err);
|
||||||
|
if (res.headersSent) return;
|
||||||
|
|
||||||
|
res.status(500).send("failed to upload file");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/ping', (_, res) => res.sendStatus(200));
|
||||||
|
|
||||||
|
app.listen(PORT, (err) => {
|
||||||
|
if (err) throw err;
|
||||||
|
console.log(`app listening on port ${PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.1",
|
||||||
|
"info": {
|
||||||
|
"title": "Scheduler API",
|
||||||
|
"description": "API for managing scheduled prompts. Cron expressions run in America/New_York (EST).",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "/",
|
||||||
|
"description": "Current server"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/api/schedules": {
|
||||||
|
"get": {
|
||||||
|
"summary": "List schedules",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Schedule"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"summary": "Create or replace a schedule",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ScheduleInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Schedule created or updated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/BadRequest"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/schedules/{name}": {
|
||||||
|
"delete": {
|
||||||
|
"summary": "Delete a schedule",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Schedule name (raw or scoped)",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Schedule deleted"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/components/responses/NotFound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"BearerAuth": {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "JWT"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"Schedule": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Scoped identifier used internally"
|
||||||
|
},
|
||||||
|
"displayName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"schedules": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Cron expression"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"startAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"oneShot": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"templateRef": {
|
||||||
|
"$ref": "#/components/schemas/TemplateRef"
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fname": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"fkey": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"userId",
|
||||||
|
"schedules",
|
||||||
|
"oneShot"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ScheduleInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"$ref": "#/components/schemas/ScheduleWhen"
|
||||||
|
},
|
||||||
|
"oneShot": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"$ref": "#/components/schemas/TemplateInput"
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fname": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"fkey": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional previously stored file reference"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Base64 encoded file contents"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"fname"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"when",
|
||||||
|
"template",
|
||||||
|
"parameters",
|
||||||
|
"prompt",
|
||||||
|
"model",
|
||||||
|
"tools"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ScheduleWhen": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cron": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "5-field cron expression evaluated in America/New_York"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Optional start gate; required when oneShot is true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
},
|
||||||
|
"TemplateInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"clusterScope": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TemplateRef": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"clusterScope": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"Unauthorized": {
|
||||||
|
"description": "Missing or invalid credentials"
|
||||||
|
},
|
||||||
|
"BadRequest": {
|
||||||
|
"description": "Invalid request payload",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Forbidden": {
|
||||||
|
"description": "Schedule exists but belongs to another user"
|
||||||
|
},
|
||||||
|
"NotFound": {
|
||||||
|
"description": "Schedule not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-242
@@ -8,242 +8,11 @@ import cron from 'node-cron'
|
|||||||
import loginUser, { getUser } from './helpers/resolve-user'
|
import loginUser, { getUser } from './helpers/resolve-user'
|
||||||
import { authCall, callNewChat, ollamaInp, schedInp } from './helpers/ollamaCalls'
|
import { authCall, callNewChat, ollamaInp, schedInp } from './helpers/ollamaCalls'
|
||||||
|
|
||||||
const DEFAULT_TZ = "America/New_York"
|
// KEEP AS STRING
|
||||||
|
const openApiSpec = fs.readFileSync('./openapi.json', 'utf-8'),
|
||||||
const openApiSpec = {
|
__filename = fileURLToPath(import.meta.url),
|
||||||
openapi: '3.0.1',
|
|
||||||
info: {
|
|
||||||
title: 'Scheduler API',
|
|
||||||
description: 'API for managing scheduled prompts. Cron expressions run in America/New_York (EST).',
|
|
||||||
version: '1.0.0'
|
|
||||||
},
|
|
||||||
servers: [
|
|
||||||
{
|
|
||||||
url: '/',
|
|
||||||
description: 'Current server'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
paths: {
|
|
||||||
'/api/schedules': {
|
|
||||||
get: {
|
|
||||||
summary: 'List schedules',
|
|
||||||
security: [{ BearerAuth: [] }],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: 'Successful response',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
ok: { type: 'boolean', example: true },
|
|
||||||
items: {
|
|
||||||
type: 'array',
|
|
||||||
items: { $ref: '#/components/schemas/Schedule' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
401: { $ref: '#/components/responses/Unauthorized' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
post: {
|
|
||||||
summary: 'Create or replace a schedule',
|
|
||||||
security: [{ BearerAuth: [] }],
|
|
||||||
requestBody: {
|
|
||||||
required: true,
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: { $ref: '#/components/schemas/ScheduleInput' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
201: {
|
|
||||||
description: 'Schedule created or updated',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
ok: { type: 'boolean', example: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
400: { $ref: '#/components/responses/BadRequest' },
|
|
||||||
401: { $ref: '#/components/responses/Unauthorized' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'/api/schedules/{name}': {
|
|
||||||
delete: {
|
|
||||||
summary: 'Delete a schedule',
|
|
||||||
security: [{ BearerAuth: [] }],
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
in: 'path',
|
|
||||||
description: 'Schedule name (raw or scoped)',
|
|
||||||
required: true,
|
|
||||||
schema: { type: 'string' }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
204: { description: 'Schedule deleted' },
|
|
||||||
401: { $ref: '#/components/responses/Unauthorized' },
|
|
||||||
403: { $ref: '#/components/responses/Forbidden' },
|
|
||||||
404: { $ref: '#/components/responses/NotFound' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
securitySchemes: {
|
|
||||||
BearerAuth: {
|
|
||||||
type: 'http',
|
|
||||||
scheme: 'bearer',
|
|
||||||
bearerFormat: 'JWT'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
schemas: {
|
|
||||||
Schedule: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
name: { type: 'string', description: 'Scoped identifier used internally' },
|
|
||||||
displayName: { type: 'string' },
|
|
||||||
userId: { type: 'string' },
|
|
||||||
schedules: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string', description: 'Cron expression' }
|
|
||||||
},
|
|
||||||
startAt: { type: 'string', format: 'date-time', nullable: true },
|
|
||||||
oneShot: { type: 'boolean' },
|
|
||||||
templateRef: { $ref: '#/components/schemas/TemplateRef' },
|
|
||||||
prompt: { type: 'string' },
|
|
||||||
model: { type: 'string' },
|
|
||||||
tools: { type: 'array', items: { type: 'string' } },
|
|
||||||
features: {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: { type: 'boolean' }
|
|
||||||
},
|
|
||||||
files: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
fname: { type: 'string' },
|
|
||||||
fkey: { type: 'string' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['name', 'userId', 'schedules', 'oneShot']
|
|
||||||
},
|
|
||||||
ScheduleInput: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
name: { type: 'string' },
|
|
||||||
when: { $ref: '#/components/schemas/ScheduleWhen' },
|
|
||||||
oneShot: { type: 'boolean', default: false },
|
|
||||||
template: { $ref: '#/components/schemas/TemplateInput' },
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: true
|
|
||||||
},
|
|
||||||
files: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
fname: { type: 'string' },
|
|
||||||
fkey: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Optional previously stored file reference'
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Base64 encoded file contents'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['fname']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
prompt: { type: 'string' },
|
|
||||||
model: { type: 'string' },
|
|
||||||
tools: { type: 'array', items: { type: 'string' } },
|
|
||||||
features: {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: { type: 'boolean' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['name', 'when', 'template', 'parameters', 'prompt', 'model', 'tools']
|
|
||||||
},
|
|
||||||
ScheduleWhen: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
cron: {
|
|
||||||
type: 'string',
|
|
||||||
description: '5-field cron expression evaluated in America/New_York'
|
|
||||||
},
|
|
||||||
start: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
description: 'Optional start gate; required when oneShot is true'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: []
|
|
||||||
},
|
|
||||||
TemplateInput: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
name: { type: 'string' },
|
|
||||||
clusterScope: { type: 'boolean', default: false }
|
|
||||||
},
|
|
||||||
required: ['name']
|
|
||||||
},
|
|
||||||
TemplateRef: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
name: { type: 'string' },
|
|
||||||
clusterScope: { type: 'boolean' }
|
|
||||||
},
|
|
||||||
required: ['name']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
Unauthorized: {
|
|
||||||
description: 'Missing or invalid credentials'
|
|
||||||
},
|
|
||||||
BadRequest: {
|
|
||||||
description: 'Invalid request payload',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
ok: { type: 'boolean', example: false },
|
|
||||||
error: { type: 'string' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Forbidden: {
|
|
||||||
description: 'Schedule exists but belongs to another user'
|
|
||||||
},
|
|
||||||
NotFound: {
|
|
||||||
description: 'Schedule not found'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url),
|
|
||||||
__dirname = path.dirname(__filename),
|
__dirname = path.dirname(__filename),
|
||||||
|
DEFAULT_TZ = "America/New_York",
|
||||||
|
|
||||||
// folders/files
|
// folders/files
|
||||||
DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'),
|
DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'),
|
||||||
@@ -254,8 +23,8 @@ const __filename = fileURLToPath(import.meta.url),
|
|||||||
|
|
||||||
let FILES_DIR = FILES_DIR_PREFERRED;
|
let FILES_DIR = FILES_DIR_PREFERRED;
|
||||||
|
|
||||||
// defaults
|
// defaults
|
||||||
PORT = Number(process.env.PORT) || 12253
|
const PORT = Number(process.env.PORT) || 12253
|
||||||
|
|
||||||
// connect to docker (via local socket by default)
|
// connect to docker (via local socket by default)
|
||||||
// const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' })
|
// const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' })
|
||||||
@@ -294,7 +63,6 @@ const normalizeFeatures = (value: unknown): Record<string, boolean> => {
|
|||||||
type IncomingFile = { fname?: string, fkey?: string, content?: string };
|
type IncomingFile = { fname?: string, fkey?: string, content?: string };
|
||||||
|
|
||||||
const sanitizeBase64 = (raw?: string) => typeof raw === 'string' ? raw.replace(/^data:[^;]+;base64,/, '') : '';
|
const sanitizeBase64 = (raw?: string) => typeof raw === 'string' ? raw.replace(/^data:[^;]+;base64,/, '') : '';
|
||||||
|
|
||||||
const prepareIncomingFiles = (value: unknown): { fname: string, fkey: string }[] | string => {
|
const prepareIncomingFiles = (value: unknown): { fname: string, fkey: string }[] | string => {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
if (!Array.isArray(value)) return 'files must be an array';
|
if (!Array.isArray(value)) return 'files must be an array';
|
||||||
@@ -302,20 +70,23 @@ const prepareIncomingFiles = (value: unknown): { fname: string, fkey: string }[]
|
|||||||
const saved: { fname: string, fkey: string }[] = [];
|
const saved: { fname: string, fkey: string }[] = [];
|
||||||
for (const entry of value as IncomingFile[]) {
|
for (const entry of value as IncomingFile[]) {
|
||||||
if (!entry || typeof entry !== 'object') return 'invalid file entry';
|
if (!entry || typeof entry !== 'object') return 'invalid file entry';
|
||||||
|
|
||||||
const fname = typeof entry.fname === 'string' ? entry.fname.trim() : '';
|
const fname = typeof entry.fname === 'string' ? entry.fname.trim() : '';
|
||||||
if (!fname) return 'file entries require fname';
|
if (!fname) return 'file entries require fname';
|
||||||
|
|
||||||
if (entry.content && typeof entry.content === 'string') {
|
if (entry.content && typeof entry.content === 'string') {
|
||||||
const b64 = sanitizeBase64(entry.content);
|
const b64 = sanitizeBase64(entry.content);
|
||||||
if (!b64) return `file ${fname} is missing content`;
|
if (!b64) return `file ${fname} is missing content`;
|
||||||
const fkey = crypto.randomUUID();
|
|
||||||
const target = path.join(FILES_DIR, fkey);
|
const fkey = crypto.randomUUID(),
|
||||||
|
target = path.join(FILES_DIR, fkey);
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(target, Buffer.from(b64, 'base64'));
|
fs.writeFileSync(target, Buffer.from(b64, 'base64'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('failed to store file', fname, err);
|
console.error('failed to store file', fname, err);
|
||||||
return `failed to store file ${fname}`;
|
return `failed to store file ${fname}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
saved.push({ fname, fkey });
|
saved.push({ fname, fkey });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -382,7 +153,7 @@ function scheduleOrReplace(defInput: ollamaInp) {
|
|||||||
const key = def.name
|
const key = def.name
|
||||||
if (tasks.has(key)) { try { tasks.get(key).task.stop() } catch { } tasks.delete(key) }
|
if (tasks.has(key)) { try { tasks.get(key).task.stop() } catch { } tasks.delete(key) }
|
||||||
|
|
||||||
const startTs = def.startAt ? new Date(def.startAt).getTime() : null;
|
const startTs = def.startAt ? new Date(def.startAt).getTime() : null;
|
||||||
|
|
||||||
// forbid overlapping runs per schedule (like argo's Forbid)
|
// forbid overlapping runs per schedule (like argo's Forbid)
|
||||||
let running = false
|
let running = false
|
||||||
@@ -505,7 +276,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
allowed = ['/', ...fs.readdirSync(publicDir).map(f => `/${f}`)];
|
allowed = ['/', ...fs.readdirSync(publicDir).map(f => `/${f}`)];
|
||||||
|
|
||||||
if (req.method === 'GET' && pathname === '/openapi.json') {
|
if (req.method === 'GET' && pathname === '/openapi.json') {
|
||||||
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(openApiSpec, null, 2));
|
return res.writeHead(200, { 'content-type': 'application/json' }).end(openApiSpec);
|
||||||
}
|
}
|
||||||
|
|
||||||
// list schedules for the calling user
|
// list schedules for the calling user
|
||||||
@@ -591,6 +362,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
const userId = await fetchUserID(req),
|
const userId = await fetchUserID(req),
|
||||||
nameParam = decodeURIComponent(paramRaw),
|
nameParam = decodeURIComponent(paramRaw),
|
||||||
|
|
||||||
// stored names are already scoped; accept either raw or scoped
|
// stored names are already scoped; accept either raw or scoped
|
||||||
key = tasks.has(nameParam) ? nameParam : scopedName(nameParam, userId),
|
key = tasks.has(nameParam) ? nameParam : scopedName(nameParam, userId),
|
||||||
existing = tasks.get(key)?.def;
|
existing = tasks.get(key)?.def;
|
||||||
|
|||||||
Reference in New Issue
Block a user