mirror of
https://github.com/thomiceli/opengist.git
synced 2026-06-23 04:10:18 +00:00
Beautiful docs website (#710)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
This commit is contained in:
@@ -16,17 +16,15 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: |
|
||||
npm install vitepress@1.3.4 tailwindcss@3.4.10
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: docs
|
||||
|
||||
- name: Build docs
|
||||
run: |
|
||||
cd docs
|
||||
npx tailwindcss -i .vitepress/theme/style.css -o .vitepress/theme/theme.css -c .vitepress/tailwind.config.js
|
||||
npm run docs:build
|
||||
run: npm run build
|
||||
working-directory: docs
|
||||
|
||||
- name: Push to docs repository
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.vitepress/dist
|
||||
.vitepress/cache
|
||||
+227
-65
@@ -1,12 +1,176 @@
|
||||
import {defineConfig} from 'vitepress'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import {listOperations, listSchemas, readSpecRaw} from './openapi'
|
||||
|
||||
// Serve the raw OpenAPI spec verbatim at /docs/api/openapi.yaml — read from the
|
||||
// Go source tree so it never drifts. Dev via middleware, build via asset emit.
|
||||
let isSsrBuild = false
|
||||
const openapiRawPlugin = {
|
||||
name: 'opengist:openapi-raw',
|
||||
configResolved(c: any) {
|
||||
isSsrBuild = !!c.build?.ssr
|
||||
},
|
||||
configureServer(server: any) {
|
||||
server.middlewares.use((req: any, res: any, next: any) => {
|
||||
if (req.url === '/docs/api/openapi.yaml') {
|
||||
res.setHeader('Content-Type', 'text/yaml; charset=utf-8')
|
||||
res.end(readSpecRaw())
|
||||
} else next()
|
||||
})
|
||||
},
|
||||
generateBundle(this: any) {
|
||||
if (isSsrBuild) return
|
||||
this.emitFile({type: 'asset', fileName: 'docs/api/openapi.yaml', source: readSpecRaw()})
|
||||
}
|
||||
}
|
||||
|
||||
// Build the API Reference sidebar from the OpenAPI spec: one entry per
|
||||
// operation (grouped by tag), then one entry per schema, plus the Overview.
|
||||
const apiOps = listOperations()
|
||||
const apiTags = [...new Set(apiOps.map(op => op.tag))]
|
||||
const apiSidebar = [
|
||||
{text: 'Overview', link: '/docs/api'},
|
||||
...apiTags.map(tag => ({
|
||||
text: tag,
|
||||
collapsed: false,
|
||||
items: apiOps
|
||||
.filter(op => op.tag === tag)
|
||||
.map(op => ({text: op.summary, link: `/docs/api/${op.id}`})),
|
||||
})),
|
||||
{
|
||||
text: 'Schemas',
|
||||
collapsed: true,
|
||||
items: listSchemas().map(name => ({text: name, link: `/docs/api/schemas/${name}`})),
|
||||
},
|
||||
]
|
||||
|
||||
// Main docs sidebar, shared by the /docs/ pages and the standalone /changelog page.
|
||||
const docsSidebar = [
|
||||
{
|
||||
text: '', items: [
|
||||
{text: 'Introduction', link: '/docs'},
|
||||
{text: 'Installation', link: '/docs/installation', items: [
|
||||
{text: 'Docker', link: '/docs/installation/docker'},
|
||||
{text: 'Kubernetes', link: '/docs/installation/kubernetes'},
|
||||
{text: 'Binary', link: '/docs/installation/binary'},
|
||||
{text: 'Source', link: '/docs/installation/source'},
|
||||
],
|
||||
collapsed: true
|
||||
},
|
||||
{text: 'Update', link: '/docs/update'},
|
||||
], collapsed: false
|
||||
},
|
||||
{
|
||||
text: 'Configuration', base: '/docs/configuration', items: [
|
||||
{text: 'Configure Opengist', link: '/configure'},
|
||||
{text: 'Databases', items: [
|
||||
{text: 'SQLite', link: '/databases/sqlite'},
|
||||
{text: 'PostgreSQL', link: '/databases/postgresql'},
|
||||
{text: 'MySQL', link: '/databases/mysql'},
|
||||
], collapsed: true
|
||||
},
|
||||
{text: 'OAuth Providers', link: '/oauth-providers'},
|
||||
{text: 'Custom assets', link: '/custom-assets'},
|
||||
{text: 'Custom links', link: '/custom-links'},
|
||||
{text: 'Cheat Sheet', link: '/cheat-sheet'},
|
||||
{text: 'Metrics', link: '/metrics'},
|
||||
{text: 'Admin panel', link: '/admin-panel'},
|
||||
], collapsed: false
|
||||
},
|
||||
{
|
||||
text: 'Usage', base: '/docs/usage', items: [
|
||||
{text: 'Init via Git', link: '/init-via-git'},
|
||||
{text: 'Embed Gist', link: '/embed'},
|
||||
{text: 'Access Tokens', link: '/access-tokens'},
|
||||
{text: 'Gist as JSON', link: '/gist-json'},
|
||||
{text: 'Import Gists from Github', link: '/import-from-github-gist'},
|
||||
{text: 'Git push options', link: '/git-push-options'},
|
||||
], collapsed: false
|
||||
},
|
||||
{
|
||||
text: 'Administration', base: '/docs/administration', items: [
|
||||
{text: 'Run with systemd', link: '/run-with-systemd'},
|
||||
{text: 'Reverse proxy', items: [
|
||||
{text: 'Nginx', link: '/nginx-reverse-proxy'},
|
||||
{text: 'Traefik', link: '/traefik-reverse-proxy'},
|
||||
], collapsed: true},
|
||||
{text: 'Fail2ban', link: '/fail2ban-setup'},
|
||||
{text: 'Healthcheck', link: '/healthcheck'},
|
||||
], collapsed: false
|
||||
},
|
||||
{
|
||||
text: 'Contributing', base: '/docs/contributing', items: [
|
||||
{text: 'Community', link: '/community'},
|
||||
{text: 'Development', link: '/development'},
|
||||
], collapsed: false
|
||||
},
|
||||
]
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
const hostname = 'https://opengist.io'
|
||||
const ogImage = `${hostname}/opengist-demo.png`
|
||||
|
||||
export default defineConfig({
|
||||
title: "Opengist",
|
||||
description: "Documention for Opengist",
|
||||
description: "Documentation for Opengist — a self-hosted pastebin powered by Git.",
|
||||
lang: 'en-US',
|
||||
sitemap: {
|
||||
hostname,
|
||||
},
|
||||
markdown: {
|
||||
config(md) {
|
||||
// Strip the "See here how to update Opengist." note that appears
|
||||
// under each version in the embedded CHANGELOG (source stays intact).
|
||||
md.core.ruler.push('strip_changelog_update_note', (state) => {
|
||||
const t = state.tokens
|
||||
for (let i = t.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
t[i].type === 'inline' &&
|
||||
/See here how to .*update.* Opengist\./i.test(t[i].content) &&
|
||||
t[i - 1]?.type === 'paragraph_open'
|
||||
) {
|
||||
t.splice(i - 1, 3)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Turn "#123" references into links to the matching GitHub PR/issue.
|
||||
md.inline.ruler.before('text', 'github_ref', (state, silent) => {
|
||||
const start = state.pos
|
||||
if (state.src.charCodeAt(start) !== 0x23 /* # */) return false
|
||||
// Require a boundary before '#' (avoid URL fragments like page#1).
|
||||
const prev = start > 0 ? state.src[start - 1] : ''
|
||||
if (prev && /[0-9A-Za-z]/.test(prev)) return false
|
||||
|
||||
let pos = start + 1
|
||||
while (pos < state.posMax && /[0-9]/.test(state.src[pos])) pos++
|
||||
if (pos === start + 1) return false // no digits
|
||||
// Reject things like a hex color "#1a2" (digit run followed by a letter).
|
||||
if (pos < state.posMax && /[A-Za-z]/.test(state.src[pos])) return false
|
||||
|
||||
const num = state.src.slice(start + 1, pos)
|
||||
if (!silent) {
|
||||
const open = state.push('link_open', 'a', 1)
|
||||
open.attrs = [
|
||||
['href', `https://github.com/thomiceli/opengist/pull/${num}`],
|
||||
['target', '_blank'],
|
||||
['rel', 'noreferrer'],
|
||||
]
|
||||
state.push('text', '', 0).content = `#${num}`
|
||||
state.push('link_close', 'a', -1)
|
||||
}
|
||||
state.pos = pos
|
||||
return true
|
||||
})
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
plugins: [tailwindcss(), openapiRawPlugin]
|
||||
},
|
||||
rewrites: {
|
||||
'index.md': 'index.md',
|
||||
'introduction.md': 'docs/index.md',
|
||||
'changelog.md': 'changelog.md',
|
||||
':path(.*)': 'docs/:path'
|
||||
},
|
||||
themeConfig: {
|
||||
@@ -14,75 +178,36 @@ export default defineConfig({
|
||||
logo: 'https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg',
|
||||
logoLink: '/',
|
||||
nav: [
|
||||
{ text: 'Demo', link: 'https://demo.opengist.io' },
|
||||
{ text: 'Translate', link: 'https://tr.opengist.io' }
|
||||
{ text: 'Docs', link: '/docs', activeMatch: '^/docs' },
|
||||
{
|
||||
text: 'Resources',
|
||||
items: [
|
||||
{ text: 'Demo', link: 'https://demo.opengist.io' },
|
||||
{ text: 'Translate', link: 'https://tr.opengist.io' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'v1.13.0',
|
||||
items: [
|
||||
{ text: 'Changelog', link: '/changelog' },
|
||||
{ text: 'Releases', link: 'https://github.com/thomiceli/opengist/releases' },
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
sidebar: {
|
||||
'/docs/': [
|
||||
{
|
||||
text: '', items: [
|
||||
{text: 'Introduction', link: '/docs'},
|
||||
{text: 'Installation', link: '/docs/installation', items: [
|
||||
{text: 'Docker', link: '/docs/installation/docker'},
|
||||
{text: 'Kubernetes', link: '/docs/installation/kubernetes'},
|
||||
{text: 'Binary', link: '/docs/installation/binary'},
|
||||
{text: 'Source', link: '/docs/installation/source'},
|
||||
],
|
||||
collapsed: true
|
||||
},
|
||||
{text: 'Update', link: '/docs/update'},
|
||||
], collapsed: false
|
||||
},
|
||||
{
|
||||
text: 'Configuration', base: '/docs/configuration', items: [
|
||||
{text: 'Configure Opengist', link: '/configure'},
|
||||
{text: 'Databases', items: [
|
||||
{text: 'SQLite', link: '/databases/sqlite'},
|
||||
{text: 'PostgreSQL', link: '/databases/postgresql'},
|
||||
{text: 'MySQL', link: '/databases/mysql'},
|
||||
], collapsed: true
|
||||
},
|
||||
{text: 'OAuth Providers', link: '/oauth-providers'},
|
||||
{text: 'Custom assets', link: '/custom-assets'},
|
||||
{text: 'Custom links', link: '/custom-links'},
|
||||
{text: 'Cheat Sheet', link: '/cheat-sheet'},
|
||||
{text: 'Metrics', link: '/metrics'},
|
||||
{text: 'Admin panel', link: '/admin-panel'},
|
||||
], collapsed: false
|
||||
},
|
||||
{
|
||||
text: 'Usage', base: '/docs/usage', items: [
|
||||
{text: 'Init via Git', link: '/init-via-git'},
|
||||
{text: 'Embed Gist', link: '/embed'},
|
||||
{text: 'Access Tokens', link: '/access-tokens'},
|
||||
{text: 'Gist as JSON', link: '/gist-json'},
|
||||
{text: 'Import Gists from Github', link: '/import-from-github-gist'},
|
||||
{text: 'Git push options', link: '/git-push-options'},
|
||||
], collapsed: false
|
||||
},
|
||||
{
|
||||
text: 'Administration', base: '/docs/administration', items: [
|
||||
{text: 'Run with systemd', link: '/run-with-systemd'},
|
||||
{text: 'Reverse proxy', items: [
|
||||
{text: 'Nginx', link: '/nginx-reverse-proxy'},
|
||||
{text: 'Traefik', link: '/traefik-reverse-proxy'},
|
||||
], collapsed: true},
|
||||
{text: 'Fail2ban', link: '/fail2ban-setup'},
|
||||
{text: 'Healthcheck', link: '/healthcheck'},
|
||||
], collapsed: false
|
||||
},
|
||||
{
|
||||
text: 'Contributing', base: '/docs/contributing', items: [
|
||||
{text: 'Community', link: '/community'},
|
||||
{text: 'Development', link: '/development'},
|
||||
], collapsed: false
|
||||
},
|
||||
|
||||
]},
|
||||
// Standalone API Reference section: its own sidebar, separate from
|
||||
// the main docs navigation. Longest-prefix match means /docs/api
|
||||
// uses this instead of the '/docs/' sidebar below.
|
||||
'/docs/api': apiSidebar,
|
||||
'/docs/': docsSidebar,
|
||||
// Standalone /changelog page reuses the main docs sidebar.
|
||||
'/changelog': docsSidebar,
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{icon: 'github', link: 'https://github.com/thomiceli/opengist'}
|
||||
{icon: 'github', link: 'https://github.com/thomiceli/opengist'},
|
||||
{icon: 'discord', link: 'https://discord.gg/9Pm3X5scZT'}
|
||||
],
|
||||
editLink: {
|
||||
pattern: 'https://github.com/thomiceli/opengist/edit/v.1.12.2/docs/:path'
|
||||
@@ -93,6 +218,43 @@ export default defineConfig({
|
||||
},
|
||||
head: [
|
||||
['link', {rel: 'icon', href: '/favicon.svg'}],
|
||||
['meta', {name: 'theme-color', content: '#3c79e2'}],
|
||||
// Site-wide Open Graph / Twitter Card defaults (per-page values are
|
||||
// refined in transformPageData below).
|
||||
['meta', {property: 'og:type', content: 'website'}],
|
||||
['meta', {property: 'og:site_name', content: 'Opengist'}],
|
||||
['meta', {property: 'og:image', content: ogImage}],
|
||||
['meta', {name: 'twitter:card', content: 'summary_large_image'}],
|
||||
['meta', {name: 'twitter:image', content: ogImage}],
|
||||
],
|
||||
// Per-page meta: canonical URL, description, and Open Graph / Twitter tags
|
||||
// built from each page's title + description.
|
||||
transformPageData(pageData) {
|
||||
// Mirror the `rewrites` above to compute the deployed path.
|
||||
let out
|
||||
if (pageData.relativePath === 'index.md') out = 'index.md'
|
||||
else if (pageData.relativePath === 'introduction.md') out = 'docs/index.md'
|
||||
else if (pageData.relativePath === 'changelog.md') out = 'changelog.md'
|
||||
else out = `docs/${pageData.relativePath}`
|
||||
const path = out.replace(/(^|\/)index\.md$/, '$1').replace(/\.md$/, '.html')
|
||||
const url = `${hostname}/${path}`
|
||||
|
||||
const base = pageData.title || 'Opengist'
|
||||
const title = base.includes('Opengist') ? base : `${base} | Opengist`
|
||||
const description =
|
||||
pageData.description ||
|
||||
pageData.frontmatter.description ||
|
||||
'Documentation for Opengist — a self-hosted pastebin powered by Git.'
|
||||
|
||||
pageData.frontmatter.head ??= []
|
||||
pageData.frontmatter.head.push(
|
||||
['link', {rel: 'canonical', href: url}],
|
||||
['meta', {property: 'og:title', content: title}],
|
||||
['meta', {property: 'og:description', content: description}],
|
||||
['meta', {property: 'og:url', content: url}],
|
||||
['meta', {name: 'twitter:title', content: title}],
|
||||
['meta', {name: 'twitter:description', content: description}],
|
||||
)
|
||||
},
|
||||
ignoreDeadLinks: true
|
||||
})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineLoader } from 'vitepress'
|
||||
import { buildApiData, SPEC_FILE, type ApiData } from './openapi'
|
||||
|
||||
// `data` is populated at build time and importable from any component.
|
||||
declare const data: ApiData
|
||||
export { data }
|
||||
export type { ApiData }
|
||||
|
||||
export default defineLoader({
|
||||
// Rebuild whenever the spec changes (path is relative to this file).
|
||||
watch: [SPEC_FILE],
|
||||
load: (): Promise<ApiData> => buildApiData()
|
||||
})
|
||||
@@ -0,0 +1,441 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
// The authoritative spec lives in the Go source tree.
|
||||
export const SPEC_FILE = '../../internal/web/handlers/api/openapi.yaml'
|
||||
const SPEC_URL = new URL(SPEC_FILE, import.meta.url)
|
||||
const METHOD_ORDER = ['get', 'post', 'put', 'patch', 'delete']
|
||||
|
||||
// ---- shapes consumed by the renderer components (all JSON-serializable) ----
|
||||
export interface ApiData {
|
||||
info: { title: string; version: string; descriptionHtml: string }
|
||||
authHtml: string
|
||||
servers: { url: string; description: string }[]
|
||||
groups: Group[]
|
||||
schemas: SchemaDef[]
|
||||
}
|
||||
export interface Group {
|
||||
name: string
|
||||
descriptionHtml: string
|
||||
operations: Operation[]
|
||||
}
|
||||
export interface Operation {
|
||||
id: string
|
||||
method: string
|
||||
path: string
|
||||
summary: string
|
||||
descriptionHtml: string
|
||||
auth: { anonymous: boolean; anyToken: boolean; scopes: string[] }
|
||||
parameters: Param[]
|
||||
requestBody: (BodyTable & { required: boolean }) | null
|
||||
responses: ResponseDef[]
|
||||
// Synthesized samples for the right-hand code panel (pre-highlighted HTML).
|
||||
sample: { curlHtml: string; responseStatus: string; responseHtml: string | null }
|
||||
}
|
||||
interface Param {
|
||||
name: string
|
||||
in: string
|
||||
required: boolean
|
||||
typeHtml: string
|
||||
descriptionHtml: string
|
||||
}
|
||||
interface Field {
|
||||
name: string
|
||||
typeHtml: string
|
||||
required: boolean
|
||||
descriptionHtml: string
|
||||
depth: number
|
||||
}
|
||||
// A body schema flattened into a field table (the inlined replacement for a
|
||||
// schema link): its type label, whether it's an array, and its fields.
|
||||
interface BodyTable {
|
||||
label: string
|
||||
arrayOf: boolean
|
||||
note: string
|
||||
rows: Field[]
|
||||
}
|
||||
interface ResponseDef {
|
||||
status: string
|
||||
descriptionHtml: string
|
||||
headers: { name: string; typeHtml: string; descriptionHtml: string }[]
|
||||
typeLabel: string | null
|
||||
}
|
||||
// A named component schema, rendered one-per-page.
|
||||
export interface SchemaDef {
|
||||
name: string
|
||||
descriptionHtml: string
|
||||
note: string
|
||||
rows: Field[]
|
||||
}
|
||||
|
||||
// Lightweight index used by config.mts (sidebar) and the dynamic-route
|
||||
// generator. Synchronous and markdown-free so it's cheap to call.
|
||||
export interface OpIndex {
|
||||
id: string
|
||||
method: string
|
||||
path: string
|
||||
summary: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
function readSpec(): any {
|
||||
return parse(readSpecRaw())
|
||||
}
|
||||
|
||||
// Raw spec contents, for serving the file verbatim at /docs/api/openapi.yaml.
|
||||
export function readSpecRaw(): string {
|
||||
return readFileSync(fileURLToPath(SPEC_URL), 'utf-8')
|
||||
}
|
||||
|
||||
// Walk paths × methods in a stable order, yielding each operation.
|
||||
function eachOperation(spec: any, cb: (op: any, method: string, path: string, item: any) => void) {
|
||||
for (const [path, item] of Object.entries<any>(spec.paths)) {
|
||||
for (const method of METHOD_ORDER) {
|
||||
const op = item[method]
|
||||
if (op) cb(op, method, path, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function listOperations(): OpIndex[] {
|
||||
const spec = readSpec()
|
||||
const ops: OpIndex[] = []
|
||||
eachOperation(spec, (op, method, path) => {
|
||||
ops.push({
|
||||
id: op.operationId,
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: op.summary || op.operationId,
|
||||
tag: op.tags?.[0] || (spec.tags?.[0]?.name ?? '')
|
||||
})
|
||||
})
|
||||
return ops
|
||||
}
|
||||
|
||||
// Names of the component schemas, for the sidebar and per-schema routes.
|
||||
export function listSchemas(): string[] {
|
||||
return Object.keys(readSpec().components?.schemas || {})
|
||||
}
|
||||
|
||||
export async function buildApiData(): Promise<ApiData> {
|
||||
// Lazily pulled in so this module can be imported from config.mts without
|
||||
// dragging the markdown renderer into the config load path.
|
||||
const { createMarkdownRenderer } = await import('vitepress')
|
||||
const spec = readSpec()
|
||||
const md = await createMarkdownRenderer(fileURLToPath(new URL('..', import.meta.url)))
|
||||
// Block rendering is async because Shiki highlighting in VitePress 2 is
|
||||
// resolved via markdown-it-async; renderInline carries no code fences.
|
||||
const html = (s?: string) => (s ? md.renderAsync(s) : Promise.resolve(''))
|
||||
const inline = (s?: string) => (s ? md.renderInline(s) : '')
|
||||
// Drop the "**Required ...**" scope line from operation descriptions — the
|
||||
// Authorization header row already documents the required scopes.
|
||||
const stripRequired = (s?: string) =>
|
||||
s
|
||||
? s
|
||||
.split('\n')
|
||||
.filter((l) => !l.trimStart().startsWith('**Required'))
|
||||
.join('\n')
|
||||
: s
|
||||
|
||||
const refName = (ref: string) => ref.split('/').pop() as string
|
||||
const resolve = (ref: string): any =>
|
||||
ref
|
||||
.replace(/^#\//, '')
|
||||
.split('/')
|
||||
.reduce((acc, key) => acc?.[key], spec)
|
||||
const deref = (obj: any): any => (obj?.$ref ? resolve(obj.$ref) : obj)
|
||||
|
||||
// Named schema references link to their per-schema page.
|
||||
const link = (name: string) => `<a href="/docs/api/schemas/${name}"><code>${name}</code></a>`
|
||||
|
||||
const typeHtml = (s: any): string => {
|
||||
if (!s) return '<code>any</code>'
|
||||
if (s.$ref) return link(refName(s.$ref))
|
||||
if (s.oneOf) return s.oneOf.map(typeHtml).join(' | ')
|
||||
if (s.allOf) return s.allOf.map(typeHtml).join(' & ')
|
||||
if (s.type === 'array') return typeHtml(s.items) + '[]'
|
||||
if (s.type === 'null') return '<code>null</code>'
|
||||
if (s.type === 'object' && s.additionalProperties)
|
||||
return `map<string, ${typeHtml(s.additionalProperties)}>`
|
||||
let t = s.type || 'object'
|
||||
if (s.format) t += ` <${s.format}>`
|
||||
let out = `<code>${t}</code>`
|
||||
if (Array.isArray(s.enum))
|
||||
out += `: ${s.enum.map((e: any) => `<code>${e}</code>`).join(' | ')}`
|
||||
return out
|
||||
}
|
||||
|
||||
const constraints = (s: any): string => {
|
||||
if (!s) return ''
|
||||
const bits: string[] = []
|
||||
if (s.default !== undefined) bits.push(`default: ${s.default}`)
|
||||
if (s.minimum !== undefined) bits.push(`min: ${s.minimum}`)
|
||||
if (s.maximum !== undefined) bits.push(`max: ${s.maximum}`)
|
||||
if (s.pattern) bits.push(`pattern: <code>${s.pattern}</code>`)
|
||||
return bits.length ? ` <em>(${bits.join(', ')})</em>` : ''
|
||||
}
|
||||
|
||||
// Flatten a schema to its full property set, resolving $ref and allOf
|
||||
// (including inherited fields) so each body can render a complete table.
|
||||
// `seen` guards against recursive schemas.
|
||||
const flatten = (
|
||||
s: any,
|
||||
seen: string[] = []
|
||||
): { props: Record<string, any>; required: string[]; addl: any } => {
|
||||
const acc = { props: {} as Record<string, any>, required: [] as string[], addl: null as any }
|
||||
if (!s) return acc
|
||||
if (s.$ref) {
|
||||
const name = refName(s.$ref)
|
||||
return seen.includes(name) ? acc : flatten(resolve(s.$ref), [...seen, name])
|
||||
}
|
||||
for (const part of s.allOf || []) {
|
||||
const f = flatten(part, seen)
|
||||
Object.assign(acc.props, f.props)
|
||||
acc.required.push(...f.required)
|
||||
if (f.addl) acc.addl = f.addl
|
||||
}
|
||||
if (s.properties) Object.assign(acc.props, s.properties)
|
||||
if (s.required) acc.required.push(...s.required)
|
||||
if (s.additionalProperties) acc.addl = s.additionalProperties
|
||||
return acc
|
||||
}
|
||||
|
||||
// Turn a request/response body schema into a renderable field table.
|
||||
const bodyTable = (schema: any): BodyTable => {
|
||||
let s = schema
|
||||
let arrayOf = false
|
||||
while (s?.$ref) s = resolve(s.$ref)
|
||||
if (s?.type === 'array') {
|
||||
arrayOf = true
|
||||
s = s.items
|
||||
while (s?.$ref) s = resolve(s.$ref)
|
||||
}
|
||||
const label = schema ? typeHtml(schema) : '<code>any</code>'
|
||||
const f = flatten(s)
|
||||
const rows: Field[] = Object.entries<any>(f.props).map(([pname, ps]) => ({
|
||||
name: pname,
|
||||
typeHtml: typeHtml(ps),
|
||||
required: f.required.includes(pname),
|
||||
descriptionHtml: inline(ps.description),
|
||||
depth: 0
|
||||
}))
|
||||
const note = f.addl ? `Additional properties: map<string, ${typeHtml(f.addl)}>` : ''
|
||||
return { label, arrayOf, note, rows }
|
||||
}
|
||||
|
||||
// Recursively expand an object/array/map schema into indented field rows,
|
||||
// descending into nested objects (used for the request body table). `seen`
|
||||
// tracks $ref names on the current branch to break recursive schemas.
|
||||
const childrenOf = (schema: any, depth: number, seen: string[]): Field[] => {
|
||||
let s = schema
|
||||
const branch = [...seen]
|
||||
while (s) {
|
||||
if (s.$ref) {
|
||||
const n = refName(s.$ref)
|
||||
if (branch.includes(n)) return []
|
||||
branch.push(n)
|
||||
s = resolve(s.$ref)
|
||||
} else if (s.type === 'array') s = s.items
|
||||
else if (s.oneOf) s = s.oneOf.find((o: any) => o.type !== 'null') ?? s.oneOf[0]
|
||||
else break
|
||||
}
|
||||
if (!s) return []
|
||||
const f = flatten(s, branch)
|
||||
const entries = Object.entries<any>(f.props)
|
||||
// A pure map (additionalProperties only): descend into the value's fields.
|
||||
if (entries.length === 0 && f.addl) return childrenOf(f.addl, depth, branch)
|
||||
const out: Field[] = []
|
||||
for (const [name, ps] of entries) {
|
||||
out.push({
|
||||
name,
|
||||
typeHtml: typeHtml(ps),
|
||||
required: f.required.includes(name),
|
||||
descriptionHtml: inline(ps.description),
|
||||
depth
|
||||
})
|
||||
out.push(...childrenOf(ps, depth + 1, branch))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Synthesize a representative value for a schema (no examples in the spec).
|
||||
// `seen` tracks $ref names on the current branch to break recursive schemas.
|
||||
const sample = (schema: any, seen: string[] = []): any => {
|
||||
if (!schema) return null
|
||||
if (schema.$ref) {
|
||||
const name = refName(schema.$ref)
|
||||
if (seen.includes(name)) return null
|
||||
return sample(resolve(schema.$ref), [...seen, name])
|
||||
}
|
||||
if (schema.example !== undefined) return schema.example
|
||||
if (schema.default !== undefined) return schema.default
|
||||
if (Array.isArray(schema.enum)) return schema.enum[0]
|
||||
if (schema.allOf)
|
||||
return schema.allOf.reduce((acc: any, s: any) => Object.assign(acc, sample(s, seen)), {})
|
||||
if (schema.oneOf) {
|
||||
const pick = schema.oneOf.find((s: any) => s.type !== 'null') ?? schema.oneOf[0]
|
||||
return sample(pick, seen)
|
||||
}
|
||||
switch (schema.type) {
|
||||
case 'array':
|
||||
return [sample(schema.items, seen)]
|
||||
case 'object': {
|
||||
const obj: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries<any>(schema.properties || {})) obj[k] = sample(v, seen)
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === 'object')
|
||||
obj['<key>'] = sample(schema.additionalProperties, seen)
|
||||
return obj
|
||||
}
|
||||
case 'integer':
|
||||
case 'number':
|
||||
return 1
|
||||
case 'boolean':
|
||||
return true
|
||||
case 'null':
|
||||
return null
|
||||
default:
|
||||
if (schema.format === 'date-time') return '2024-01-01T00:00:00Z'
|
||||
if (schema.format === 'uri') return 'https://opengist.example/...'
|
||||
if (schema.format === 'binary') return '<binary data>'
|
||||
return 'string'
|
||||
}
|
||||
}
|
||||
|
||||
const HOST = 'https://opengist.example.com'
|
||||
const base = spec.servers?.[0]?.url ?? ''
|
||||
const fence = (lang: string, code: string) => md.renderAsync(`\`\`\`${lang}\n${code}\n\`\`\``)
|
||||
|
||||
const tagDefs: any[] = spec.tags || []
|
||||
const groups: Group[] = []
|
||||
for (const t of tagDefs)
|
||||
groups.push({ name: t.name, descriptionHtml: await html(t.description), operations: [] })
|
||||
const groupByName = new Map(groups.map((g) => [g.name, g]))
|
||||
|
||||
for (const [path, item] of Object.entries<any>(spec.paths)) {
|
||||
const pathParams: any[] = (item.parameters || []).map(deref)
|
||||
for (const method of METHOD_ORDER) {
|
||||
const op = item[method]
|
||||
if (!op) continue
|
||||
const allParams: any[] = [...pathParams, ...(op.parameters || []).map(deref)]
|
||||
|
||||
const sec: any[] = op.security ?? spec.security ?? []
|
||||
const auth = { anonymous: false, anyToken: false, scopes: [] as string[] }
|
||||
const scopeSet = new Set<string>()
|
||||
for (const req of sec) {
|
||||
if (Object.keys(req).length === 0) auth.anonymous = true
|
||||
else {
|
||||
const arr: string[] = req.bearerAuth || []
|
||||
if (arr.length === 0) auth.anyToken = true
|
||||
else arr.forEach((s) => scopeSet.add(s))
|
||||
}
|
||||
}
|
||||
auth.scopes = [...scopeSet]
|
||||
|
||||
const parameters: Param[] = allParams.map((p) => ({
|
||||
name: p.name,
|
||||
in: p.in,
|
||||
required: !!p.required,
|
||||
typeHtml: typeHtml(p.schema),
|
||||
descriptionHtml: inline(p.description) + constraints(p.schema)
|
||||
}))
|
||||
|
||||
let requestBody: Operation['requestBody'] = null
|
||||
let requestSchema: any = null
|
||||
if (op.requestBody) {
|
||||
const rb = deref(op.requestBody)
|
||||
requestSchema = rb.content?.['application/json']?.schema
|
||||
// Expand nested object fields inline so the whole body shape is visible.
|
||||
requestBody = {
|
||||
required: !!rb.required,
|
||||
...bodyTable(requestSchema),
|
||||
rows: childrenOf(requestSchema, 0, [])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- curl request sample: -X, then --url, then headers / body ----
|
||||
const query = allParams
|
||||
.filter((p) => p.in === 'query' && (p.required || p.schema?.default !== undefined))
|
||||
.map((p) => `${p.name}=${p.schema?.default ?? sample(p.schema)}`)
|
||||
.join('&')
|
||||
const url = `${HOST}${base}${path}${query ? '?' + query : ''}`
|
||||
const curlLines = [
|
||||
`curl -X ${method.toUpperCase()}`,
|
||||
` --url "${url}"`,
|
||||
` -H "Authorization: Bearer og_xxxxxxxx"`
|
||||
]
|
||||
if (requestSchema) {
|
||||
curlLines.push(` -H "Content-Type: application/json"`)
|
||||
curlLines.push(` -d '${JSON.stringify(sample(requestSchema), null, 2)}'`)
|
||||
}
|
||||
const curlHtml = await fence('bash', curlLines.join(' \\\n'))
|
||||
|
||||
// ---- example response (first 2xx with a JSON body) for the code panel ----
|
||||
let responseStatus = ''
|
||||
let responseHtml: string | null = null
|
||||
for (const [status, resRaw] of Object.entries<any>(op.responses)) {
|
||||
if (!status.startsWith('2')) continue
|
||||
responseStatus = status
|
||||
const schema = deref(resRaw).content?.['application/json']?.schema
|
||||
if (schema) responseHtml = await fence('json', JSON.stringify(sample(schema), null, 2))
|
||||
break
|
||||
}
|
||||
|
||||
const responses: ResponseDef[] = Object.entries<any>(op.responses).map(([status, resRaw]) => {
|
||||
const res = deref(resRaw)
|
||||
const content = res.content || {}
|
||||
const ct = content['application/json'] || Object.values<any>(content)[0]
|
||||
return {
|
||||
status,
|
||||
descriptionHtml: inline(res.description),
|
||||
headers: Object.entries<any>(res.headers || {}).map(([name, hRaw]) => {
|
||||
const h = deref(hRaw)
|
||||
return { name, typeHtml: typeHtml(h.schema), descriptionHtml: inline(h.description) }
|
||||
}),
|
||||
typeLabel: ct?.schema ? typeHtml(ct.schema) : null
|
||||
}
|
||||
})
|
||||
|
||||
const built: Operation = {
|
||||
id: op.operationId,
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: op.summary || '',
|
||||
descriptionHtml: await html(stripRequired(op.description)),
|
||||
auth,
|
||||
parameters,
|
||||
requestBody,
|
||||
responses,
|
||||
sample: { curlHtml, responseStatus, responseHtml }
|
||||
}
|
||||
;(groupByName.get(op.tags?.[0]) ?? groups[0])?.operations.push(built)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- component schemas, each rendered on its own page ----
|
||||
const schemas: SchemaDef[] = []
|
||||
for (const [name, s] of Object.entries<any>(spec.components?.schemas || {})) {
|
||||
const t = bodyTable(s)
|
||||
schemas.push({
|
||||
name,
|
||||
descriptionHtml: await html(s.description),
|
||||
note: t.note,
|
||||
rows: t.rows
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
info: {
|
||||
title: spec.info.title,
|
||||
version: spec.info.version,
|
||||
descriptionHtml: await html(spec.info.description)
|
||||
},
|
||||
authHtml: await html(spec.components?.securitySchemes?.bearerAuth?.description),
|
||||
servers: (spec.servers || []).map((s: any) => ({
|
||||
url: s.url,
|
||||
description: s.description || ''
|
||||
})),
|
||||
groups,
|
||||
schemas
|
||||
}
|
||||
}
|
||||
Vendored
-37
@@ -1,37 +0,0 @@
|
||||
const colors = require('tailwindcss/colors')
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./.vitepress/theme/*.vue",
|
||||
],
|
||||
theme: {
|
||||
colors: {
|
||||
transparent: 'transparent',
|
||||
current: 'currentColor',
|
||||
white: colors.white,
|
||||
black: colors.black,
|
||||
gray: {
|
||||
50: "#EEEFF1",
|
||||
100: "#DEDFE3",
|
||||
200: "#BABCC5",
|
||||
300: "#999CA8",
|
||||
400: "#75798A",
|
||||
500: "#585B68",
|
||||
600: "#464853",
|
||||
700: "#363840",
|
||||
800: "#232429",
|
||||
900: "#131316"
|
||||
},
|
||||
indigo: colors.indigo,
|
||||
|
||||
},
|
||||
extend: {
|
||||
borderWidth: {
|
||||
'1': '1px',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
darkMode: 'class',
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<script>
|
||||
import { withBase } from 'vitepress';
|
||||
import './theme.css'
|
||||
|
||||
export default {
|
||||
|
||||
setup() {
|
||||
return { withBase };
|
||||
},
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<main class="home">
|
||||
<header class="hero">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div class="mx-auto lg:text-center">
|
||||
<img class="rotating h-36 mx-auto my-8 " src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg" alt="" >
|
||||
<a target="_blank" href="https://github.com/thomiceli/opengist/releases" class="inline-flex items-center rounded-full bg-indigo-100 hover:bg-indigo-200 px-4 py-1.5 text-lg font-medium text-indigo-700">
|
||||
<span class="pr-1">Released 1.12</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="mt-5 text-4xl font-bold tracking-tight sm:text-5xl">Opengist</h1>
|
||||
<h2 class="mt-4 text-xl">Self-hosted pastebin powered by Git, open-source alternative to Github Gist.</h2>
|
||||
</div>
|
||||
<div class="space-x-2 my-12">
|
||||
<a href="/docs" class="rounded-md bg-indigo-600 mt-6 px-5 py-3 text-xl font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Docs</a>
|
||||
<a target="_blank" href="https://demo.opengist.io" class="rounded-md bg-indigo-400 mt-6 px-5 py-3 text-xl border-white font-semibold text-white shadow-sm hover:bg-indigo-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Live demo</a>
|
||||
<a target="_blank" href="https://github.com/thomiceli/opengist" class="rounded-md bg-gray-800 mt-6 px-3 py-3 text-xl dark:border dark:border-1 dark:border-gray-400 font-semibold text-white shadow-sm hover:bg-gray-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
|
||||
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 16 16" class="w-7 h-auto inline" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"></path></svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="border border-1 mt-6 px-5 py-3 rounded-md shadow-sm ">
|
||||
<code class="select-all ">docker run --name <span class="text-indigo-700 dark:text-indigo-300 font-bold">opengist</span> -p <span class="text-indigo-700 dark:text-indigo-300 font-bold">6157</span>:6157 -v "<span class="text-indigo-700 dark:text-indigo-300 font-bold">$HOME/.opengist</span>:/opengist" ghcr.io/thomiceli/opengist:1</code>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="relative w-full sm:max-w-7xl mx-auto overflow-auto">
|
||||
<img class="block w-[200vw] max-w-none sm:w-full h-auto" :src="withBase('/opengist-demo.png')" alt="demo-opengist-screenshot" />
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
@-webkit-keyframes rotating /* Safari and Chrome */ {
|
||||
from {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-o-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-o-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes rotating {
|
||||
from {
|
||||
-ms-transform: rotate(0deg);
|
||||
-moz-transform: rotate(0deg);
|
||||
-webkit-transform: rotate(0deg);
|
||||
-o-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
-ms-transform: rotate(360deg);
|
||||
-moz-transform: rotate(360deg);
|
||||
-webkit-transform: rotate(360deg);
|
||||
-o-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.home {
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rotating {
|
||||
-webkit-animation: rotating 8s linear infinite;
|
||||
-moz-animation: rotating 4s linear infinite;
|
||||
-ms-animation: rotating 4s linear infinite;
|
||||
-o-animation: rotating 4s linear infinite;
|
||||
animation: rotating 12s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
@@ -1,16 +1,21 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useData } from 'vitepress'
|
||||
import Home from './Home.vue'
|
||||
import Home from './layouts/Home.vue'
|
||||
import SectionNav from './components/SectionNav.vue'
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
|
||||
const { Layout } = DefaultTheme
|
||||
const { frontmatter } = useData()
|
||||
|
||||
const isHome = computed(() => frontmatter.value.layout === 'home')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionNav v-if="!isHome" />
|
||||
<Layout>
|
||||
<template v-if="frontmatter.layout === 'home'" #home-hero-after>
|
||||
<template v-if="isHome" #home-hero-after>
|
||||
<Home />
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { data } from '../../openapi.data'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
|
||||
const op = computed(() =>
|
||||
data.groups.flatMap((g) => g.operations).find((o) => o.id === props.id)
|
||||
)
|
||||
|
||||
// Split the path so `{param}` segments can be highlighted.
|
||||
const pathParts = computed(() =>
|
||||
(op.value?.path ?? '')
|
||||
.split(/(\{[^}]+\})/g)
|
||||
.filter(Boolean)
|
||||
.map((text) => ({ text, param: text.startsWith('{') && text.endsWith('}') }))
|
||||
)
|
||||
|
||||
// The Authorization header is implied by the spec's bearerAuth security scheme
|
||||
// rather than declared as a parameter, so synthesize a row from `auth`.
|
||||
const authHeader = computed(() => {
|
||||
const a = op.value?.auth
|
||||
if (!a || (!a.scopes.length && !a.anyToken && !a.anonymous)) return null
|
||||
const desc = a.scopes.map((s) => `<code>${s}</code>`).join(', ')
|
||||
return {
|
||||
name: 'Authorization',
|
||||
typeHtml: '<code>string</code>',
|
||||
required: !a.anonymous,
|
||||
descriptionHtml: desc
|
||||
}
|
||||
})
|
||||
|
||||
// Request parameters grouped by location, in display order, empty groups dropped.
|
||||
const paramGroups = computed(() => {
|
||||
const o = op.value
|
||||
if (!o) return []
|
||||
const headers = [
|
||||
...(authHeader.value ? [authHeader.value] : []),
|
||||
...o.parameters.filter((p) => p.in === 'header')
|
||||
]
|
||||
const groups = [
|
||||
{ label: 'Headers', descLabel: 'Scopes', optional: true, items: headers },
|
||||
{ label: 'Path parameters', descLabel: 'Description', optional: false, items: o.parameters.filter((p) => p.in === 'path') },
|
||||
{ label: 'Query parameters', descLabel: 'Description', optional: false, items: o.parameters.filter((p) => p.in === 'query') }
|
||||
]
|
||||
return groups.filter((g) => g.items.length)
|
||||
})
|
||||
|
||||
const hasRequest = computed(() => paramGroups.value.length > 0 || !!op.value?.requestBody)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="op" class="oas-op-grid">
|
||||
<!-- left / center: description, request, responses -->
|
||||
<div class="oas-main">
|
||||
<h1>{{ op.summary }}</h1>
|
||||
<div class="oas-endpoint">
|
||||
<span class="oas-method" :class="`m-${op.method.toLowerCase()}`">{{ op.method }}</span>
|
||||
<code class="oas-path"><span
|
||||
v-for="(part, i) in pathParts"
|
||||
:key="i"
|
||||
:class="{ 'oas-param': part.param }"
|
||||
>{{ part.text }}</span></code>
|
||||
</div>
|
||||
|
||||
<div class="oas-desc" v-html="op.descriptionHtml" />
|
||||
|
||||
<template v-if="hasRequest">
|
||||
<h2>Request</h2>
|
||||
|
||||
<template v-for="g in paramGroups" :key="g.label">
|
||||
<h3>{{ g.label }}</h3>
|
||||
<table class="oas-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Type</th><th>{{ g.descLabel }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in g.items" :key="p.name">
|
||||
<td>
|
||||
<code>{{ p.name }}</code>
|
||||
<span v-if="p.required" class="oas-req">required</span>
|
||||
<span v-else-if="g.optional" class="oas-opt">optional</span>
|
||||
</td>
|
||||
<td v-html="p.typeHtml" />
|
||||
<td v-html="p.descriptionHtml" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template v-if="op.requestBody">
|
||||
<h3>Body parameters</h3>
|
||||
<p class="oas-bodyhead">
|
||||
<span v-html="op.requestBody.label" />
|
||||
<span v-if="op.requestBody.required" class="oas-req">required</span>
|
||||
<span class="oas-ct">application/json</span>
|
||||
</p>
|
||||
<table v-if="op.requestBody.rows.length" class="oas-table">
|
||||
<thead>
|
||||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in op.requestBody.rows" :key="i" :class="{ 'oas-nested': row.depth }">
|
||||
<td>
|
||||
<span class="oas-field" :style="{ paddingLeft: row.depth * 1.25 + 'rem' }">
|
||||
<span v-if="row.depth" class="oas-nest">↳</span>
|
||||
<code>{{ row.name }}</code>
|
||||
<span v-if="row.required" class="oas-req">required</span>
|
||||
<span v-else class="oas-opt">optional</span>
|
||||
</span>
|
||||
</td>
|
||||
<td v-html="row.typeHtml" />
|
||||
<td v-html="row.descriptionHtml" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="op.requestBody.note" class="oas-note" v-html="op.requestBody.note" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<h2>Responses</h2>
|
||||
<table class="oas-table">
|
||||
<thead>
|
||||
<tr><th>Status</th><th>Body</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="r in op.responses" :key="r.status">
|
||||
<tr>
|
||||
<td><code class="oas-status" :class="`s-${r.status[0]}`">{{ r.status }}</code></td>
|
||||
<td><span v-if="r.typeLabel" v-html="r.typeLabel" /><span v-else>—</span></td>
|
||||
<td v-html="r.descriptionHtml" />
|
||||
</tr>
|
||||
<tr v-if="r.headers.length" class="oas-headers">
|
||||
<td></td>
|
||||
<td colspan="2">
|
||||
<span class="oas-hlabel">Headers:</span>
|
||||
<span v-for="h in r.headers" :key="h.name" class="oas-header"><code>{{ h.name }}</code></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- right: code samples -->
|
||||
<aside class="oas-code">
|
||||
<div class="oas-code-sticky">
|
||||
<div class="oas-code-label">Request</div>
|
||||
<div class="oas-code-block" v-html="op.sample.curlHtml" />
|
||||
<template v-if="op.sample.responseHtml">
|
||||
<div class="oas-code-label">
|
||||
Response
|
||||
<code class="oas-status" :class="`s-${op.sample.responseStatus[0]}`">{{ op.sample.responseStatus }}</code>
|
||||
</div>
|
||||
<div class="oas-code-block" v-html="op.sample.responseHtml" />
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div v-else><p>Unknown operation: <code>{{ id }}</code></p></div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { data } from '../../openapi.data'
|
||||
|
||||
const props = defineProps<{ name: string }>()
|
||||
const schema = computed(() => data.schemas.find((s) => s.name === props.name))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="schema" class="oas-schema">
|
||||
<h1>{{ schema.name }}</h1>
|
||||
<div v-html="schema.descriptionHtml" />
|
||||
<table v-if="schema.rows.length" class="oas-table">
|
||||
<thead>
|
||||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in schema.rows" :key="row.name">
|
||||
<td>
|
||||
<code>{{ row.name }}</code>
|
||||
<span v-if="row.required" class="oas-req">required</span>
|
||||
</td>
|
||||
<td v-html="row.typeHtml" />
|
||||
<td v-html="row.descriptionHtml" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="schema.note" class="oas-note" v-html="schema.note" />
|
||||
</div>
|
||||
<div v-else><p>Unknown schema: <code>{{ name }}</code></p></div>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, withBase } from 'vitepress'
|
||||
|
||||
const route = useRoute()
|
||||
const isApi = computed(() => route.path.startsWith(withBase('/docs/api')))
|
||||
|
||||
const items = computed(() => [
|
||||
{ text: 'Guide', link: '/docs', active: !isApi.value },
|
||||
{ text: 'API', link: '/docs/api', active: isApi.value },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="og-sectionbar">
|
||||
<div class="og-sectionbar__inner">
|
||||
<a
|
||||
v-for="it in items"
|
||||
:key="it.link"
|
||||
:href="withBase(it.link)"
|
||||
class="og-sectionbar__link"
|
||||
:class="{ 'is-active': it.active }"
|
||||
>
|
||||
{{ it.text }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.og-sectionbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.og-sectionbar {
|
||||
position: fixed;
|
||||
top: var(--vp-nav-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 28;
|
||||
display: block;
|
||||
height: var(--og-bar-height);
|
||||
background-color: var(--vp-nav-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Same container + frame borders as the top navbar so the bar lines up: full
|
||||
--vp-layout-max-width centered, 32px inner padding, and 2px vertical + bottom
|
||||
borders matching the rest of the frame. */
|
||||
.og-sectionbar__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
height: 100%;
|
||||
max-width: var(--vp-layout-max-width);
|
||||
margin: 0 auto;
|
||||
padding-inline: 32px;
|
||||
border-inline: var(--og-frame-border) solid var(--vp-c-divider);
|
||||
border-bottom: var(--og-frame-border) solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.og-sectionbar__link {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.og-sectionbar__link:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.og-sectionbar__link.is-active {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
.og-sectionbar__link.is-active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
const features = [
|
||||
{
|
||||
title: 'Public, unlisted & private',
|
||||
desc: 'Set the visibility of every snippet - public, unlisted, or fully private.',
|
||||
},
|
||||
{
|
||||
title: 'Powered by Git',
|
||||
desc: 'Each snippet is a Git repository. Clone and push over HTTP or SSH, or create one with a plain git push.',
|
||||
},
|
||||
{
|
||||
title: 'Git CLI & API',
|
||||
desc: 'Manage your snippets straight from the git command line, or via the API.',
|
||||
},
|
||||
{
|
||||
title: 'Revisions & diffs',
|
||||
desc: 'Browse the full history of a snippet with diffs between every revision.',
|
||||
},
|
||||
{
|
||||
title: 'Rich rendering',
|
||||
desc: 'Syntax highlighting for hundreds of languages, plus Markdown with Mermaid, LaTeX, CSV and media files.',
|
||||
},
|
||||
{
|
||||
title: 'Files & uploads',
|
||||
desc: 'Multiple files per snippet, including binary files and images, with a download as ZIP.',
|
||||
},
|
||||
{
|
||||
title: 'Likes, forks & topics',
|
||||
desc: 'Star and fork snippets, and organize them with topics for easy discovery.',
|
||||
},
|
||||
{
|
||||
title: 'Embed & JSON',
|
||||
desc: 'Drop a snippet into any webpage with the embed widget, or fetch it as JSON.',
|
||||
},
|
||||
{
|
||||
title: 'Full-text & code search',
|
||||
desc: 'Search across snippets via code or metadata.',
|
||||
},
|
||||
{
|
||||
title: 'OAuth, LDAP & passkeys',
|
||||
desc: 'Log in with GitHub, GitLab, Gitea or OIDC, LDAP, and secure accounts with TOTP or WebAuthn.',
|
||||
},
|
||||
{
|
||||
title: 'Admin & instance controls',
|
||||
desc: 'Require login for a private instance, allow anonymous snippets, manage users, and expose Prometheus metrics.',
|
||||
},
|
||||
{
|
||||
title: 'Self-hosted your way',
|
||||
desc: 'A single binary, Docker image or K8s deployment, backed by SQLite, PostgreSQL or MySQL.',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="og-features">
|
||||
<div class="og-features__head">
|
||||
<h2 class="og-features__title">Features</h2>
|
||||
<p class="og-features__desc">
|
||||
All you need to self-host, manage and share your snippets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="og-features__grid">
|
||||
<li v-for="f in features" :key="f.title" class="og-feature">
|
||||
<h3 class="og-feature__title">{{ f.title }}</h3>
|
||||
<p class="og-feature__desc">{{ f.desc }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.og-features {
|
||||
border-bottom: var(--og-frame-border) solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.og-features__head {
|
||||
padding: 3rem 1.5rem 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.og-features__title {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.og-features__desc {
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* Flush grid: cells separated by the same frame border (no radius, no margin). */
|
||||
.og-features__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--og-frame-border);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: var(--vp-c-divider);
|
||||
border-top: var(--og-frame-border) solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.og-feature {
|
||||
padding: 1.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
.og-feature__title {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.og-feature__desc {
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.og-features__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.og-features__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,338 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const version = 'v1.13'
|
||||
const dockerCommand = 'docker run -p 6157:6157 -v "$HOME/.opengist:/opengist" ghcr.io/thomiceli/opengist:1.13'
|
||||
|
||||
const stars = ref(null)
|
||||
const formattedStars = computed(() => {
|
||||
const n = stars.value
|
||||
if (n == null) return ''
|
||||
// if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
|
||||
return String(n)
|
||||
})
|
||||
|
||||
// Cache the star count in localStorage so we don't hit the GitHub API on every
|
||||
// page load / navigation (unauthenticated API is rate-limited to 60/hour/IP).
|
||||
const STARS_KEY = 'og-gh-stars'
|
||||
const STARS_TTL = 6 * 60 * 60 * 1000 // 6 hours
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(STARS_KEY) || 'null')
|
||||
if (cached && Date.now() - cached.ts < STARS_TTL) {
|
||||
stars.value = cached.value
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/thomiceli/opengist')
|
||||
if (res.ok) {
|
||||
stars.value = (await res.json()).stargazers_count
|
||||
try {
|
||||
localStorage.setItem(STARS_KEY, JSON.stringify({ value: stars.value, ts: Date.now() }))
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
const copied = ref(false)
|
||||
let timer
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(dockerCommand)
|
||||
copied.value = true
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => (copied.value = false), 1600)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="og-hero">
|
||||
<a
|
||||
class="og-badge"
|
||||
href="https://github.com/thomiceli/opengist/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="og-badge__dot" />
|
||||
{{ version }} released: Restful API + brand new docs page
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="og-badge__arrow">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m9 6 6 6-6 6" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<img
|
||||
class="og-logo"
|
||||
src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg"
|
||||
alt="Opengist"
|
||||
/>
|
||||
|
||||
<h1 class="og-title">
|
||||
<span class="og-title__shine">Opengist</span>
|
||||
</h1>
|
||||
|
||||
<p class="og-tagline">
|
||||
Self-hosted and open-source pastebin powered by Git
|
||||
</p>
|
||||
|
||||
<div class="og-actions">
|
||||
<a class="og-btn og-btn--primary" href="/docs">Get started</a>
|
||||
<a class="og-btn" href="https://demo.opengist.io" target="_blank" rel="noopener noreferrer">
|
||||
Live demo
|
||||
</a>
|
||||
<a
|
||||
class="og-btn og-btn--github"
|
||||
href="https://github.com/thomiceli/opengist"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Opengist on GitHub"
|
||||
>
|
||||
<svg class="og-btn__gh" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8" />
|
||||
</svg>
|
||||
<span v-if="formattedStars" class="og-btn__stars">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="m12 17.27 6.18 3.73-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
{{ formattedStars }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="og-install">
|
||||
<span class="og-install__prompt">$</span>
|
||||
<code class="og-install__cmd">{{ dockerCommand }}</code>
|
||||
<button class="og-install__copy" type="button" aria-label="Copy command" @click="copy">
|
||||
<svg v-if="copied" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m5 13 4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" />
|
||||
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.og-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2.5rem 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.og-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.825rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-soft);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
.og-badge:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
.og-badge__dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
.og-badge__arrow {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
}
|
||||
|
||||
.og-logo {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
margin: 2rem 0 1.25rem;
|
||||
}
|
||||
|
||||
.og-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.25rem, 6vw, 2.75rem);
|
||||
line-height: 1.18;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.og-title__shine {
|
||||
display: block;
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
var(--vp-c-text-1) 0%,
|
||||
var(--vp-c-text-1) 40%,
|
||||
var(--vp-c-brand-1) 50%,
|
||||
var(--vp-c-text-1) 60%,
|
||||
var(--vp-c-text-1) 100%
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
background-position: 100% 0;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: og-shine 6s ease-in-out 0.3s 1 forwards;
|
||||
}
|
||||
@keyframes og-shine {
|
||||
to {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.og-tagline {
|
||||
max-width: 34rem;
|
||||
margin: 1.2rem 0 0;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.og-license {
|
||||
margin: 0.85rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.og-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.og-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 2.875rem;
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.og-btn:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
.og-btn--primary {
|
||||
border-color: transparent;
|
||||
background: var(--vp-c-brand-3);
|
||||
color: #fff;
|
||||
}
|
||||
.og-btn--primary:hover {
|
||||
background: var(--vp-c-brand-2);
|
||||
color: #fff;
|
||||
}
|
||||
.og-btn--icon {
|
||||
width: 2.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
.og-btn--icon svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
}
|
||||
|
||||
/* GitHub button with star count. */
|
||||
.og-btn--github {
|
||||
gap: 0.6rem;
|
||||
padding: 0 1.1rem;
|
||||
}
|
||||
.og-btn__gh {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
}
|
||||
.og-btn__stars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding-left: 0.6rem;
|
||||
border-left: 1px solid var(--vp-c-divider);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.og-btn__stars svg {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
color: #e3b341;
|
||||
}
|
||||
|
||||
.og-install {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
max-width: 100%;
|
||||
margin-top: 2rem;
|
||||
padding: 0.6rem 0.6rem 0.6rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
.og-install__prompt {
|
||||
color: var(--vp-c-brand-1);
|
||||
user-select: none;
|
||||
}
|
||||
.og-install__cmd {
|
||||
flex: 1;
|
||||
min-width: 0; /* allow the long command to shrink + scroll instead of overflowing */
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.og-install__copy {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.og-install__copy:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
.og-install__copy svg {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.og-hero {
|
||||
padding: 2.5rem 1.25rem 2.5rem;
|
||||
}
|
||||
.og-install {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.og-hero {
|
||||
padding-top: 3.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import { useData, withBase } from 'vitepress'
|
||||
|
||||
const { isDark } = useData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="og-showcase">
|
||||
<div class="og-showcase__text">
|
||||
<h2 class="og-showcase__title">A clean, familiar interface</h2>
|
||||
<p class="og-showcase__desc">
|
||||
Create, browse and share snippets with syntax highlighting, revisions and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="og-showcase__frame">
|
||||
<img
|
||||
v-show="!isDark"
|
||||
class="og-showcase__img"
|
||||
:src="withBase('/opengist-demo.png')"
|
||||
alt="Opengist web interface"
|
||||
/>
|
||||
<img
|
||||
v-show="isDark"
|
||||
class="og-showcase__img"
|
||||
:src="withBase('/opengist-demo-dark.png')"
|
||||
alt="Opengist web interface"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.og-showcase {
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.og-showcase {
|
||||
padding: 2rem 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.og-showcase__text {
|
||||
max-width: 34rem;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
.og-showcase__title {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.og-showcase__desc {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* Smaller, centered screenshot frame. */
|
||||
.og-showcase__frame {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.og-showcase__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -2,11 +2,16 @@ import { h } from 'vue'
|
||||
import type { Theme } from 'vitepress'
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import Layout from "./Layout.vue";
|
||||
import OpenApiOperation from "./components/OpenApiOperation.vue";
|
||||
import OpenApiSchema from "./components/OpenApiSchema.vue";
|
||||
import './style.css'
|
||||
import './openapi.css'
|
||||
|
||||
export default {
|
||||
...DefaultTheme,
|
||||
Layout,
|
||||
enhanceApp({ app, router, siteData }) {
|
||||
// ...
|
||||
app.component('OpenApiOperation', OpenApiOperation)
|
||||
app.component('OpenApiSchema', OpenApiSchema)
|
||||
}
|
||||
} satisfies Theme
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import Hero from '../components/home/Hero.vue'
|
||||
import Showcase from '../components/home/Showcase.vue'
|
||||
import Features from '../components/home/Features.vue'
|
||||
// Add further sections here as the landing page grows (e.g. Features, FAQ).
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="og-home">
|
||||
<div class="og-wrapper">
|
||||
<Hero />
|
||||
<Features />
|
||||
<Showcase />
|
||||
<!-- Empty growing section so the side borders run to the bottom. -->
|
||||
<div class="og-fill" />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.og-home {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Centered content column framed with vertical borders (vite-plus style).
|
||||
Sections inside add their own border-bottom to act as horizontal rules.
|
||||
Width matches the navbar container so the home content aligns with the header. */
|
||||
.og-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: var(--vp-layout-max-width);
|
||||
margin: 0 auto;
|
||||
/* Fill the viewport below the navbar so the side borders run to the bottom
|
||||
even when the content is shorter than the screen. */
|
||||
min-height: calc(100vh - var(--vp-nav-height));
|
||||
/* Gap inside the frame, so the left/right borders stay visible through it. */
|
||||
padding-bottom: 2rem;
|
||||
border-inline: var(--og-frame-border) solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
/* Empty section that absorbs the remaining height, extending the side borders. */
|
||||
.og-fill {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,138 @@
|
||||
/* Shared styling for the generated API reference components. */
|
||||
.oas-op h3,
|
||||
.oas-schema h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.oas-method {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
}
|
||||
.oas-method.m-get { background: #1f7a4d; }
|
||||
.oas-method.m-post { background: #1860c2; }
|
||||
.oas-method.m-put { background: #b06800; }
|
||||
.oas-method.m-patch { background: #8754c9; }
|
||||
.oas-method.m-delete { background: #c0392b; }
|
||||
/* Endpoint shown as a code block: method badge on the left, then the path. */
|
||||
.oas-endpoint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0 1.5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
/* Plain path text (no inline-code bg/color); only `.oas-param` is highlighted. */
|
||||
.vp-doc code.oas-path {
|
||||
font-size: 0.95rem;
|
||||
background: none;
|
||||
color: var(--vp-c-text-1);
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Highlight `{param}` segments within the path. */
|
||||
.oas-param {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-default-soft);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.oas-req {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-red-1);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.oas-opt {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-3);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.oas-ct {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
/* `.vp-doc table` is display:block / fit-content, so force full-width here. */
|
||||
.vp-doc table.oas-table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.oas-table th { text-align: left; }
|
||||
.oas-status { font-weight: 700; }
|
||||
.oas-status.s-2 { color: #1f7a4d; }
|
||||
.oas-status.s-4,
|
||||
.oas-status.s-5 { color: #c0392b; }
|
||||
.oas-headers td {
|
||||
padding-top: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
.oas-hlabel { margin-right: 0.4rem; }
|
||||
.oas-header { margin-right: 0.4rem; }
|
||||
.oas-includes a { margin-right: 0.5rem; }
|
||||
.oas-note { font-size: 0.85rem; color: var(--vp-c-text-2); }
|
||||
.oas-bodyhead { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.oas-field { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
.oas-nest { color: var(--vp-c-text-3); }
|
||||
.oas-nested { background: var(--vp-c-bg-soft); }
|
||||
|
||||
/* ---- three-pane endpoint layout (pages with pageClass: api-page) ---- */
|
||||
.api-page .VPDoc .container { max-width: 1180px; }
|
||||
.api-page .VPDoc.has-sidebar .content { max-width: none; }
|
||||
.api-page .VPDoc .content-container { max-width: none; }
|
||||
|
||||
.oas-op-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 26rem;
|
||||
gap: 2.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.oas-main { min-width: 0; }
|
||||
.oas-main h1 { margin-top: 0; }
|
||||
|
||||
.oas-code-sticky {
|
||||
position: sticky;
|
||||
top: calc(var(--vp-nav-height) + 1.5rem);
|
||||
}
|
||||
.oas-code-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
.oas-code-label:first-child { margin-top: 0; }
|
||||
/* Smaller, denser code in the side panel. VitePress applies
|
||||
`font-size: var(--vp-code-font-size)` to the <code>, so override the var. */
|
||||
.oas-code {
|
||||
--vp-code-font-size: 0.75rem;
|
||||
--vp-code-line-height: 1.5;
|
||||
}
|
||||
.oas-code-block [class*='language-'] { margin: 0; }
|
||||
.oas-code-block pre {
|
||||
white-space: pre;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
/* Collapse to a single column when there isn't room for the side panel. */
|
||||
@media (max-width: 1280px) {
|
||||
.oas-op-grid { grid-template-columns: 1fr; }
|
||||
.oas-code-sticky { position: static; }
|
||||
}
|
||||
@@ -1,3 +1,22 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Map Tailwind's `dark:` variant onto VitePress's `.dark` class. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Opengist brand gray palette (replaces Tailwind's default gray). */
|
||||
@theme {
|
||||
--color-gray-50: #EEEFF1;
|
||||
--color-gray-100: #DEDFE3;
|
||||
--color-gray-200: #BABCC5;
|
||||
--color-gray-300: #999CA8;
|
||||
--color-gray-400: #75798A;
|
||||
--color-gray-500: #585B68;
|
||||
--color-gray-600: #464853;
|
||||
--color-gray-700: #363840;
|
||||
--color-gray-800: #232429;
|
||||
--color-gray-900: #131316;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customize default theme styling by overriding CSS variables:
|
||||
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
|
||||
@@ -49,10 +68,11 @@
|
||||
--vp-c-default-3: var(--vp-c-gray-3);
|
||||
--vp-c-default-soft: var(--vp-c-gray-soft);
|
||||
|
||||
--vp-c-brand-1: var(--vp-c-indigo-1);
|
||||
--vp-c-brand-2: var(--vp-c-indigo-2);
|
||||
--vp-c-brand-3: var(--vp-c-indigo-3);
|
||||
--vp-c-brand-soft: var(--vp-c-indigo-soft);
|
||||
/* Opengist brand blue (matches --color-primary-* in the app). */
|
||||
--vp-c-brand-1: #3c79e2;
|
||||
--vp-c-brand-2: #356fc0;
|
||||
--vp-c-brand-3: #3c79e2;
|
||||
--vp-c-brand-soft: rgba(60, 121, 226, 0.14);
|
||||
|
||||
--vp-c-tip-1: var(--vp-c-brand-1);
|
||||
--vp-c-tip-2: var(--vp-c-brand-2);
|
||||
@@ -70,6 +90,19 @@
|
||||
--vp-c-danger-soft: var(--vp-c-red-soft);
|
||||
}
|
||||
|
||||
/* Dark theme background (brand gray-900). */
|
||||
.dark {
|
||||
--vp-c-bg: #131316;
|
||||
--vp-c-bg-alt: #0f0f12;
|
||||
--vp-c-bg-elv: #1a1a1e;
|
||||
|
||||
/* Lighter brand blue for contrast on dark backgrounds. */
|
||||
--vp-c-brand-1: #74a4f6;
|
||||
--vp-c-brand-2: #588fee;
|
||||
--vp-c-brand-3: #3c79e2;
|
||||
--vp-c-brand-soft: rgba(116, 164, 246, 0.16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Button
|
||||
* -------------------------------------------------------------------------- */
|
||||
@@ -142,6 +175,150 @@
|
||||
height: 108px;
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* Home: drop VitePress's bottom margin on the home container so our framed
|
||||
wrapper (and its side borders) reaches the very bottom of the page. */
|
||||
.VPHome {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navbar: left-align the menu (Docs / Resources) next to the logo,
|
||||
* keeping the appearance toggle and social links on the right.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
@media (min-width: 768px) {
|
||||
/* Drop VitePress's `margin-right: -100vw; padding-right: 100vw` background
|
||||
bleed: it makes the flex free space negative, which neutralises the auto
|
||||
margin below. The bar's own background covers the width anyway. */
|
||||
.VPNavBar .content-body {
|
||||
margin-right: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
/* The (empty) search slot has flex-grow:1 and would eat all the free space,
|
||||
leaving none for the menu's auto margin. Stop it from growing. */
|
||||
.VPNavBar .VPNavBarSearch {
|
||||
flex-grow: 0 !important;
|
||||
}
|
||||
.VPNavBar .VPNavBarMenu {
|
||||
margin-right: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the navbar identical on the landing page and the docs pages:
|
||||
VitePress otherwise makes it transparent and borderless on the home page. */
|
||||
.VPNavBar {
|
||||
background-color: var(--vp-nav-bg-color) !important;
|
||||
}
|
||||
/* Bottom border: match the side borders' style (2px, --vp-c-divider) and keep
|
||||
it the same width as the container instead of spanning the whole page. */
|
||||
.VPNavBar .divider {
|
||||
max-width: var(--vp-layout-max-width);
|
||||
margin-inline: auto;
|
||||
}
|
||||
.VPNavBar .divider-line {
|
||||
height: var(--og-frame-border);
|
||||
background-color: var(--vp-c-divider) !important;
|
||||
}
|
||||
|
||||
/* Drop the bottom border under the logo/title (shown on docs pages). */
|
||||
.VPNavBarTitle.has-sidebar .title {
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Remove the small vertical divider lines between the menu, theme toggle
|
||||
(.appearance) and social links in the navbar. */
|
||||
.VPNavBar .menu + .translations::before,
|
||||
.VPNavBar .menu + .appearance::before,
|
||||
.VPNavBar .menu + .social-links::before,
|
||||
.VPNavBar .translations + .appearance::before,
|
||||
.VPNavBar .appearance + .social-links::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The dividers also provided the spacing between modules; add it back. */
|
||||
.VPNavBar .appearance,
|
||||
.VPNavBar .social-links {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
/* Match the home content layout: full --vp-layout-max-width container centered
|
||||
(native margin: 0 auto), with no side padding on the wrapper. The container
|
||||
carries the same vertical borders as the home .og-wrapper, with 32px of
|
||||
inner padding so the nav content sits inside the borders. */
|
||||
.VPNavBar .wrapper {
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
.VPNavBar .container {
|
||||
max-width: var(--vp-layout-max-width) !important;
|
||||
padding-inline: 32px;
|
||||
border-inline: var(--og-frame-border) solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
/* On docs pages the navbar gets `.has-sidebar`, which offsets the title to the
|
||||
sidebar width and changes the container. Undo it so the navbar lays out the
|
||||
same as on the landing page (centered, max-width container). */
|
||||
@media (min-width: 960px) {
|
||||
.VPNavBar.has-sidebar .wrapper {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.VPNavBar.has-sidebar .container {
|
||||
max-width: var(--vp-layout-max-width) !important;
|
||||
}
|
||||
.VPNavBar.has-sidebar .title {
|
||||
position: static !important;
|
||||
width: auto !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.VPNavBar.has-sidebar .content {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.VPNavBar.has-sidebar .divider {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Horizontal section bar (Guide / API) below the top navbar. Desktop only;
|
||||
on mobile the top-nav menu provides section switching. */
|
||||
:root {
|
||||
--og-bar-height: 48px;
|
||||
/* Frame border width, between 1px and 2px without using px (0.1rem ≈ 1.6px). */
|
||||
--og-frame-border: 0.09rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
/* Push the sidebar, main content and outline down by the bar height. */
|
||||
.VPSidebar {
|
||||
padding-top: calc(var(--vp-nav-height) + var(--og-bar-height)) !important;
|
||||
}
|
||||
.VPContent.has-sidebar {
|
||||
padding-top: calc(var(--vp-nav-height) + var(--og-bar-height)) !important;
|
||||
}
|
||||
.VPDoc {
|
||||
--vp-doc-top-height: var(--og-bar-height);
|
||||
}
|
||||
}
|
||||
|
||||
/* Continue the navbar/home frame down the docs pages: the sidebar gets the
|
||||
outer-left border and the sidebar/content divider, and the content (.VPDoc)
|
||||
gets the outer-right border. Same style as the rest of the frame. */
|
||||
@media (min-width: 960px) {
|
||||
.VPSidebar {
|
||||
border-left: var(--og-frame-border) solid var(--vp-c-divider);
|
||||
border-right: var(--og-frame-border) solid var(--vp-c-divider);
|
||||
}
|
||||
.VPDoc {
|
||||
border-right: var(--og-frame-border) solid var(--vp-c-divider);
|
||||
}
|
||||
}
|
||||
|
||||
/* At >=1440px the layout is centered, so pull the sidebar box in to start at the
|
||||
frame's left edge (same X as the navbar's left border) instead of the viewport
|
||||
edge. Content/nav positions are unchanged; only the box origin and width move. */
|
||||
@media (min-width: 1440px) {
|
||||
.VPSidebar {
|
||||
left: calc((100% - var(--vp-layout-max-width)) / 2) !important;
|
||||
width: var(--vp-sidebar-width) !important;
|
||||
padding-left: 32px !important;
|
||||
}
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
---
|
||||
aside: true
|
||||
---
|
||||
|
||||
# Opengist API Reference
|
||||
|
||||
Opengist exposes a REST API authenticated with Personal Access Tokens, intended
|
||||
for programmatic access to gist and user resources.
|
||||
|
||||
The base URL for the API is
|
||||
```
|
||||
https://opengist.example.com/api/
|
||||
```
|
||||
|
||||
> OpenAPI 3.1 spec is available at
|
||||
> [`openapi.yaml`](api/openapi.yaml).
|
||||
>
|
||||
> A running instance also serves the raw spec at `GET /api/openapi.yaml`.
|
||||
|
||||
## Getting an access token
|
||||
|
||||
The API authenticates with a Personal Access Token. To create one:
|
||||
|
||||
1. Go to **Settings**
|
||||
2. Select the **Access Tokens** menu
|
||||
3. Choose a name, select the scopes the token should grant and an optional
|
||||
expiration date, then click **Create Access Token**
|
||||
4. Copy the token (starting with `og_`). It is shown only once.
|
||||
|
||||
Tokens carry per-resource scopes, each at read or read/write level:
|
||||
|
||||
| Scope | Grants |
|
||||
|-------|--------|
|
||||
| `gist:read` | Read gists, including the caller's private and unlisted ones |
|
||||
| `gist:write` | Create, update, delete and fork gists |
|
||||
| `user:read` | Read the authenticated user's account |
|
||||
| `user:write` | Update the authenticated user and toggle likes |
|
||||
|
||||
## Authentication
|
||||
|
||||
Send the token in the `Authorization` header using the `Bearer` scheme:
|
||||
|
||||
```
|
||||
Authorization: Bearer og_xxxxxxxx
|
||||
```
|
||||
|
||||
Each endpoint documents the scope it requires in its **Headers** section.
|
||||
|
||||
|
||||
## Schema
|
||||
|
||||
The API lives under the `/api/` prefix on the same host as your Opengist
|
||||
instance. All data is sent and received as JSON.
|
||||
|
||||
Every endpoint responds with JSON unless specified otherwise
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
All timestamps are returned in ISO 8601 / RFC 3339 format, in UTC:
|
||||
```
|
||||
2024-01-01T00:00:00Z
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
List endpoints (such as `GET /gists`) return a JSON array and page the results.
|
||||
Tune the window with these query parameters:
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `page` | integer | `1` | Page number, 1-based. |
|
||||
| `per_page` | integer | `30` | Items per page (maximum `100`). |
|
||||
| `since` | string (date-time) | — | Gist lists only: return only gists updated at or after this RFC 3339 timestamp. |
|
||||
|
||||
Pagination metadata is returned in the response headers:
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `Link` | RFC 5988 links to other pages: `rel="next"` (when more pages exist) and `rel="prev"` (when `page > 1`). |
|
||||
| `X-Page` | The current page number (1-based). |
|
||||
| `X-Per-Page` | Items per page. |
|
||||
| `X-Total` | Total number of items across all pages. |
|
||||
| `X-Total-Pages` | Total number of pages, i.e. `ceil(total / per_page)`. |
|
||||
|
||||
The `Link` header is formatted as follows:
|
||||
|
||||
```
|
||||
Link: <https://opengist.example/api/gists?page=2&per_page=30>; rel="next",
|
||||
<https://opengist.example/api/gists?page=1&per_page=30>; rel="prev"
|
||||
```
|
||||
|
||||
|
||||
## Disabling the API
|
||||
|
||||
The API is **enabled by default**. To disable it, define it in the config as follows
|
||||
|
||||
### YAML
|
||||
```yaml
|
||||
api.enabled: false
|
||||
```
|
||||
|
||||
### Environment variable
|
||||
```shell
|
||||
OG_API_ENABLED=false
|
||||
```
|
||||
|
||||
While disabled, the routing layer returns `403` for every endpoint until it is
|
||||
enabled again. Disabling does not revoke issued tokens — they resume working
|
||||
once the API is turned back on.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
aside: false
|
||||
pageClass: api-page
|
||||
---
|
||||
<script setup>
|
||||
import { useData } from 'vitepress'
|
||||
const { params } = useData()
|
||||
</script>
|
||||
|
||||
<OpenApiOperation :id="params.id" />
|
||||
@@ -0,0 +1,10 @@
|
||||
import { listOperations } from '../.vitepress/openapi'
|
||||
|
||||
// One generated page per operation, keyed by operationId.
|
||||
export default {
|
||||
paths() {
|
||||
return listOperations().map((op) => ({
|
||||
params: { operation: op.id, id: op.id, title: `${op.method} ${op.path}` }
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
<script setup>
|
||||
import { useData } from 'vitepress'
|
||||
const { params } = useData()
|
||||
</script>
|
||||
|
||||
<OpenApiSchema :name="params.name" />
|
||||
@@ -0,0 +1,8 @@
|
||||
import { listSchemas } from '../../.vitepress/openapi'
|
||||
|
||||
// One generated page per component schema, keyed by its name.
|
||||
export default {
|
||||
paths() {
|
||||
return listSchemas().map((name) => ({ params: { schema: name, name } }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Release notes and version history for Opengist — new features, fixes and changes in every release.
|
||||
outline: [2, 2]
|
||||
---
|
||||
|
||||
<!--@include: ../CHANGELOG.md-->
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
---
|
||||
layout: home
|
||||
navbar: false
|
||||
title: Opengist — self-hosted pastebin powered by Git
|
||||
titleTemplate: false
|
||||
description: Opengist is an open-source, self-hosted pastebin powered by Git — a lightweight alternative to GitHub Gist that you host and fully control.
|
||||
---
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
description: Install Opengist with Docker, Kubernetes, a prebuilt binary, or from source — pick the method that fits your environment.
|
||||
---
|
||||
|
||||
# Install Opengist
|
||||
|
||||
There are several ways to install Opengist, depending on your preferences and your environment.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
description: Opengist is a self-hosted pastebin powered by Git. Snippets are stored in Git repositories and managed via standard Git commands or the web interface.
|
||||
---
|
||||
|
||||
# Opengist
|
||||
|
||||
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg" alt="Opengist" align="right" />
|
||||
|
||||
Generated
+3367
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "opengist-docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Documentation site for Opengist, built with VitePress.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vitepress dev",
|
||||
"build": "vitepress build",
|
||||
"preview": "vitepress preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"vitepress": "^2.0.0-alpha.17",
|
||||
"vue": "^3.5.27",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 702 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 666 KiB |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://opengist.io/sitemap.xml
|
||||
@@ -1,196 +0,0 @@
|
||||
# REST API
|
||||
|
||||
Opengist exposes a REST API authenticated with Personal Access Tokens, intended for programmatic access to gist resources.
|
||||
|
||||
> **Authoritative OpenAPI 3.1 spec**: [`internal/web/handlers/api/openapi.yaml`](../../internal/web/handlers/api/openapi.yaml)
|
||||
> **Live spec endpoint**: `GET /api/v1/openapi.yaml` on a running instance
|
||||
>
|
||||
> Import that URL into Postman, Insomnia, Bruno, Hoppscotch, or `openapi-generator` for an interactive UI or a generated client.
|
||||
|
||||
## Enabling the API
|
||||
|
||||
The API is **disabled by default**. An administrator must enable it explicitly:
|
||||
|
||||
1. Sign in as an administrator
|
||||
2. Open **Admin Panel → Configuration**
|
||||
3. Toggle **Enable REST API at /api/v1**
|
||||
|
||||
Disabling the API later does not revoke issued tokens; the routing layer simply returns `503` until it is enabled again.
|
||||
|
||||
## Creating a Personal Access Token
|
||||
|
||||
1. Sign in and open **Settings → Access Tokens**
|
||||
2. Enter a name (e.g. `cli`) and choose scopes:
|
||||
- **Gist scope**: `Read` for read-only access; `Read+Write` to create, update or delete gists
|
||||
- **User scope**: `Read` is required to call `/api/v1/user`
|
||||
3. Optionally set an expiry date
|
||||
4. After submitting, the token is shown **once** — copy it immediately. Tokens look like `og_<64 hex>`.
|
||||
|
||||
## Authentication
|
||||
|
||||
Send the token in the `Authorization` header (Bearer recommended):
|
||||
|
||||
```
|
||||
Authorization: Bearer og_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
The legacy `Authorization: Token og_xxx` form (used by the existing `.json` endpoints) is also accepted.
|
||||
|
||||
## Error responses
|
||||
|
||||
All errors share the same shape:
|
||||
|
||||
```json
|
||||
{ "error": "human readable message", "code": "machine_readable_code" }
|
||||
```
|
||||
|
||||
Common codes:
|
||||
|
||||
| HTTP | code | Meaning |
|
||||
|------|------|---------|
|
||||
| 401 | `unauthorized` | Missing / invalid / expired token |
|
||||
| 403 | `forbidden` | Token lacks the required scope |
|
||||
| 404 | `not_found` | Resource does not exist or is not visible to this token |
|
||||
| 400 | `validation_failed` | Request body is invalid |
|
||||
| 503 | `api_disabled` | Administrator has disabled the API |
|
||||
| 500 | `internal_error` | Server-side failure |
|
||||
|
||||
When the API is disabled, the `503` response also includes a `hint` field pointing administrators to the toggle.
|
||||
|
||||
## Endpoints
|
||||
|
||||
All endpoints are prefixed with `/api/v1`.
|
||||
|
||||
### `GET /user` — current user
|
||||
|
||||
Requires the `user:read` scope.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer og_xxx" https://opengist.example/api/v1/user
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"is_admin": false,
|
||||
"created_at": "2026-05-16T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /gists` — list gists
|
||||
|
||||
Requires the `gist:read` scope.
|
||||
|
||||
Query parameters:
|
||||
- `page` (default `1`)
|
||||
- `per_page` (default `10`, max `100`)
|
||||
- `visibility` — `mine` (default; only gists owned by the current token's user) or `public` (site-wide public gists)
|
||||
|
||||
Response `200`:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"uuid": "abc123",
|
||||
"title": "Hello",
|
||||
"description": "",
|
||||
"visibility": "public",
|
||||
"html_url": "/alice/abc123",
|
||||
"created_at": "2026-05-16T00:00:00Z",
|
||||
"updated_at": "2026-05-16T00:00:00Z",
|
||||
"owner": {"id": 1, "username": "alice"},
|
||||
"files": [{"filename": "a.txt", "size": 11}]
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"per_page": 10,
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /gists` — create a gist
|
||||
|
||||
Requires the `gist:write` scope.
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer og_xxx" -H "Content-Type: application/json" \
|
||||
https://opengist.example/api/v1/gists \
|
||||
-d '{"title":"Hello","visibility":"public","files":[{"filename":"a.txt","content":"hello"}]}'
|
||||
```
|
||||
|
||||
Response `201`: the full gist object including file contents.
|
||||
|
||||
### `GET /gists/{uuid}` — fetch a gist
|
||||
|
||||
Requires the `gist:read` scope. Private gists are only visible to their owner.
|
||||
|
||||
### `PATCH /gists/{uuid}` — update a gist
|
||||
|
||||
Requires the `gist:write` scope. The caller must be the owner. All body fields are optional:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "New title",
|
||||
"description": "...",
|
||||
"visibility": "unlisted",
|
||||
"files": [{"filename": "a.txt", "content": "new content"}]
|
||||
}
|
||||
```
|
||||
|
||||
**`files` semantics**: providing the field **replaces all files**; omitting it leaves the existing files untouched.
|
||||
|
||||
### `DELETE /gists/{uuid}` — delete a gist
|
||||
|
||||
Requires the `gist:write` scope. Owner only. Returns `204 No Content`.
|
||||
|
||||
### `GET /gists/{uuid}/files/{filename}/raw` — raw file contents
|
||||
|
||||
Requires the `gist:read` scope. Returns `text/plain` with the raw bytes.
|
||||
|
||||
## Known limitations (v1)
|
||||
|
||||
- No rate limiting (do it at the reverse proxy if you need it)
|
||||
- No binary file upload (string `content` only for now)
|
||||
- No `user=`, `sort=`, or `order=` filters on the list endpoint
|
||||
- No Like / Fork / Search / Revisions / Webhook endpoints
|
||||
- No SSH key, invitation code, or admin operation endpoints
|
||||
- For `visibility=public`, the `total` field is approximated by the current page size (the underlying query caps at 11 rows)
|
||||
- File lists return at most 11 entries; `per_page>10` will not fetch more
|
||||
|
||||
These will be addressed in subsequent versions.
|
||||
|
||||
## End-to-end example
|
||||
|
||||
```bash
|
||||
TOK=og_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
BASE=http://localhost:6157
|
||||
|
||||
# Current user
|
||||
curl -s -H "Authorization: Bearer $TOK" $BASE/api/v1/user | jq
|
||||
|
||||
# Create a gist
|
||||
curl -s -X POST -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
|
||||
$BASE/api/v1/gists \
|
||||
-d '{"title":"E2E","visibility":"public","files":[{"filename":"hello.txt","content":"hi"}]}' \
|
||||
| jq
|
||||
|
||||
# List
|
||||
curl -s -H "Authorization: Bearer $TOK" "$BASE/api/v1/gists?per_page=5" | jq
|
||||
|
||||
# Fetch one (replace UUID)
|
||||
curl -s -H "Authorization: Bearer $TOK" $BASE/api/v1/gists/<UUID> | jq
|
||||
|
||||
# Raw file
|
||||
curl -s -H "Authorization: Bearer $TOK" $BASE/api/v1/gists/<UUID>/files/hello.txt/raw
|
||||
|
||||
# Patch
|
||||
curl -s -X PATCH -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
|
||||
$BASE/api/v1/gists/<UUID> -d '{"title":"E2E renamed"}' | jq
|
||||
|
||||
# Delete
|
||||
curl -s -X DELETE -H "Authorization: Bearer $TOK" -o /dev/null -w "%{http_code}\n" \
|
||||
$BASE/api/v1/gists/<UUID>
|
||||
```
|
||||
@@ -317,7 +317,7 @@ admin.disable-login: Disable login form
|
||||
admin.disable-login_help: Forbid logging in via the login form to force using OAuth providers instead.
|
||||
admin.disable-gravatar: Disable Gravatar
|
||||
admin.disable-gravatar_help: Disable the usage of Gravatar as an avatar provider.
|
||||
admin.api-enabled: Enable REST API at /api/v1
|
||||
admin.api-enabled: Enable REST API at /api
|
||||
admin.api-enabled_help: Allow programmatic access to gists and user info via Personal Access Tokens.
|
||||
|
||||
admin.users.delete_confirm: Do you want to delete this user ?
|
||||
|
||||
@@ -195,7 +195,7 @@ admin.disable-login: 禁用登录表单
|
||||
admin.disable-login_help: 禁止使用登录表单进行登录以强制通过 OAuth 提供方登录。
|
||||
admin.disable-gravatar: 禁用 Gravatar
|
||||
admin.disable-gravatar_help: 停止使用 Gravatar 作为头像提供方。
|
||||
admin.api-enabled: 启用 REST API(/api/v1)
|
||||
admin.api-enabled: 启用 REST API(/api)
|
||||
admin.api-enabled_help: 允许通过 Personal Access Token 程序化访问 Gist 和用户信息。
|
||||
admin.allow-gists-without-login: 允许未登录状态下访问单个 Gists
|
||||
admin.allow-gists-without-login_help: 允许在不登录的情况下查看和下载 Gist,同时需要登录才能使用 Gists 的发现功能。
|
||||
|
||||
@@ -6,7 +6,7 @@ info:
|
||||
description: |
|
||||
REST API for Opengist.
|
||||
|
||||
The base URL is `/api/v1`.
|
||||
The base URL is `/api`.
|
||||
|
||||
Authentication uses a Personal Access Token sent in the `Authorization`
|
||||
header using the `Bearer` scheme:
|
||||
@@ -15,7 +15,7 @@ info:
|
||||
|
||||
Example usage:
|
||||
|
||||
curl -H "Authorization: Bearer og_xxxxxxxx" https://opengist.local/api/v1/gists
|
||||
curl -H "Authorization: Bearer og_xxxxxxxx" https://opengist.local/api/gists
|
||||
|
||||
Tokens are created from the web UI under Settings → Access Tokens and carry
|
||||
per-resource scopes (gist, user) at read or read/write level.
|
||||
@@ -24,7 +24,7 @@ info:
|
||||
url: https://github.com/thomiceli/opengist/blob/master/LICENSE
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
- url: /api
|
||||
description: Current instance
|
||||
|
||||
security:
|
||||
@@ -473,7 +473,7 @@ paths:
|
||||
- { $ref: "#/components/parameters/GistUuidParam" }
|
||||
get:
|
||||
operationId: checkGistLike
|
||||
summary: Check whether the caller likes a gist
|
||||
summary: Check if a gist is liked
|
||||
tags: [Gists]
|
||||
security:
|
||||
- bearerAuth: [] # any valid token (no specific scope required)
|
||||
|
||||
@@ -11,6 +11,6 @@ func TestOpenAPISpec(t *testing.T) {
|
||||
s := webtest.Setup(t)
|
||||
defer webtest.Teardown(t)
|
||||
|
||||
resp := s.Request(t, "GET", "/api/v1/openapi.yaml", nil, 200)
|
||||
resp := s.Request(t, "GET", "/api/openapi.yaml", nil, 200)
|
||||
require.Contains(t, resp.Header.Get("Content-Type"), "yaml")
|
||||
}
|
||||
|
||||
@@ -59,10 +59,10 @@ func parseLinkHeader(t *testing.T, h string) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// listAnonymous fires an anonymous GET /api/v1/gists?... and returns both the
|
||||
// listAnonymous fires an anonymous GET /api/gists?... and returns both the
|
||||
// decoded array and the Link header (parsed by rel).
|
||||
func listAnonymous(t *testing.T, s *webtest.Server, query string) ([]types.GistSimple, map[string]string) {
|
||||
return apiList[types.GistSimple](t, s, "/api/v1/gists?"+query, "", 200)
|
||||
return apiList[types.GistSimple](t, s, "/api/gists?"+query, "", 200)
|
||||
}
|
||||
|
||||
// apiList fires a GET against a list endpoint and returns the decoded array
|
||||
@@ -159,7 +159,7 @@ func TestPage_BelowOne_FallsBackToOne(t *testing.T) {
|
||||
func TestPagination_TotalHeaders(t *testing.T) {
|
||||
s := setupPaginationEnv(t, 5)
|
||||
|
||||
w, body := s.APIRequest(t, "GET", "/api/v1/gists?per_page=2", "", nil, 200)
|
||||
w, body := s.APIRequest(t, "GET", "/api/gists?per_page=2", "", nil, 200)
|
||||
var arr []types.GistSimple
|
||||
require.NoError(t, json.Unmarshal(body, &arr))
|
||||
|
||||
@@ -173,7 +173,7 @@ func TestPagination_TotalHeaders(t *testing.T) {
|
||||
func TestLinkHeader_SinglePage_NoHeader(t *testing.T) {
|
||||
s := setupPaginationEnv(t, 1)
|
||||
|
||||
w, _ := s.APIRequest(t, "GET", "/api/v1/gists?per_page=10", "", nil, 200)
|
||||
w, _ := s.APIRequest(t, "GET", "/api/gists?per_page=10", "", nil, 200)
|
||||
require.Empty(t, w.Header().Get("Link"),
|
||||
"single-page response must omit the Link header (no prev, no next)")
|
||||
}
|
||||
@@ -199,5 +199,5 @@ func TestSince_Past_ReturnsAll(t *testing.T) {
|
||||
func TestSince_InvalidFormat_400(t *testing.T) {
|
||||
s := setupPaginationEnv(t, 1)
|
||||
|
||||
s.APIRequest(t, "GET", "/api/v1/gists?since=not-a-date", "", nil, 400)
|
||||
s.APIRequest(t, "GET", "/api/gists?since=not-a-date", "", nil, 400)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func lookupGistByUUID(ctx *context.Context, uuid string) (*db.Gist, error) {
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// GetGist handles GET /api/v1/gists/:uuid.
|
||||
// GetGist handles GET /api/gists/:uuid.
|
||||
// Public and unlisted gists are readable by anyone (including anonymous
|
||||
// callers), matching the rest of the API's soft-scope rule. Private gists
|
||||
// only resolve for their owner with a
|
||||
@@ -55,7 +55,7 @@ func GetGist(ctx *context.Context) error {
|
||||
return ctx.JSON(200, resp)
|
||||
}
|
||||
|
||||
// GetGistRevision handles GET /api/v1/gists/:uuid/:sha.
|
||||
// GetGistRevision handles GET /api/gists/:uuid/:sha.
|
||||
// Same shape as GetGist, but returns the gist as it stood at the given
|
||||
// commit SHA instead of HEAD. Visibility rules are identical (lookup-side
|
||||
// check). An unknown revision surfaces as 404 - same code as the not-found
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
|
||||
)
|
||||
|
||||
// ListCommits handles GET /api/v1/gists/:uuid/commits.
|
||||
// ListCommits handles GET /api/gists/:uuid/commits.
|
||||
// Each commit's author is
|
||||
// resolved to an Opengist user via db.Gist.Log's bulk email lookup (see
|
||||
// db.GistCommit) so the API and the web revisions page share the same
|
||||
|
||||
@@ -20,7 +20,7 @@ func makeCommits(t *testing.T, s *webtest.Server, tok string, count int) string
|
||||
})
|
||||
for i := 1; i < count; i++ {
|
||||
body := fmt.Sprintf(`{"files": {"a.txt": {"content": "v%d"}}}`, i)
|
||||
s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok, body, 200)
|
||||
s.APIRequest(t, "PATCH", "/api/gists/"+id, tok, body, 200)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -64,14 +64,14 @@ func TestListCommits_VisibilityAccess(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s.APIRequest(t, "GET", "/api/v1/gists/"+c.uuid+"/commits", c.tok, nil, c.want)
|
||||
s.APIRequest(t, "GET", "/api/gists/"+c.uuid+"/commits", c.tok, nil, c.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCommits_NotFound(t *testing.T) {
|
||||
s := setupGetGist(t)
|
||||
s.APIRequest(t, "GET", "/api/v1/gists/does-not-exist/commits", "", nil, 404)
|
||||
s.APIRequest(t, "GET", "/api/gists/does-not-exist/commits", "", nil, 404)
|
||||
}
|
||||
|
||||
// --- Response shape ---
|
||||
@@ -84,7 +84,7 @@ func TestListCommits_Shape(t *testing.T) {
|
||||
s := setupGetGist(t)
|
||||
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
|
||||
|
||||
commits, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+gist.Uuid+"/commits", "", 200)
|
||||
commits, _ := apiList[types.GistCommit](t, s, "/api/gists/"+gist.Uuid+"/commits", "", 200)
|
||||
|
||||
require.Len(t, commits, 1, "fresh gist must have exactly one commit")
|
||||
c := commits[0]
|
||||
@@ -123,7 +123,7 @@ func TestListCommits_UserResolutionByEmail(t *testing.T) {
|
||||
|
||||
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
|
||||
|
||||
commits, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+gist.Uuid+"/commits", "", 200)
|
||||
commits, _ := apiList[types.GistCommit](t, s, "/api/gists/"+gist.Uuid+"/commits", "", 200)
|
||||
require.Len(t, commits, 1)
|
||||
|
||||
c := commits[0]
|
||||
@@ -139,7 +139,7 @@ func TestListCommits_PerPage_LimitsResults(t *testing.T) {
|
||||
s, tok := setupCreateGist(t)
|
||||
id := makeCommits(t, s, tok, 5)
|
||||
|
||||
commits, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2", tok, 200)
|
||||
commits, _ := apiList[types.GistCommit](t, s, "/api/gists/"+id+"/commits?per_page=2", tok, 200)
|
||||
require.Len(t, commits, 2)
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestListCommits_OmitsTotalHeaders(t *testing.T) {
|
||||
s, tok := setupCreateGist(t)
|
||||
id := makeCommits(t, s, tok, 3)
|
||||
|
||||
w, _ := s.APIRequest(t, "GET", "/api/v1/gists/"+id+"/commits?per_page=2", tok, nil, 200)
|
||||
w, _ := s.APIRequest(t, "GET", "/api/gists/"+id+"/commits?per_page=2", tok, nil, 200)
|
||||
require.Empty(t, w.Header().Get("X-Total"), "commits must not emit X-Total")
|
||||
require.Empty(t, w.Header().Get("X-Total-Pages"), "commits must not emit X-Total-Pages")
|
||||
require.Equal(t, "1", w.Header().Get("X-Page"))
|
||||
@@ -163,7 +163,7 @@ func TestListCommits_Page_FirstPage_OnlyNext(t *testing.T) {
|
||||
s, tok := setupCreateGist(t)
|
||||
id := makeCommits(t, s, tok, 5)
|
||||
|
||||
commits, rels := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2&page=1", tok, 200)
|
||||
commits, rels := apiList[types.GistCommit](t, s, "/api/gists/"+id+"/commits?per_page=2&page=1", tok, 200)
|
||||
require.Len(t, commits, 2)
|
||||
|
||||
require.Contains(t, rels, "next", "first page must advertise rel=next when more rows exist")
|
||||
@@ -180,7 +180,7 @@ func TestListCommits_Page_MiddlePage_PrevAndNext(t *testing.T) {
|
||||
s, tok := setupCreateGist(t)
|
||||
id := makeCommits(t, s, tok, 5)
|
||||
|
||||
commits, rels := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2&page=2", tok, 200)
|
||||
commits, rels := apiList[types.GistCommit](t, s, "/api/gists/"+id+"/commits?per_page=2&page=2", tok, 200)
|
||||
require.Len(t, commits, 2)
|
||||
|
||||
require.Contains(t, rels, "next", "middle page must advertise rel=next")
|
||||
@@ -191,9 +191,9 @@ func TestListCommits_Page_AcrossPagesNoDuplicates(t *testing.T) {
|
||||
s, tok := setupCreateGist(t)
|
||||
id := makeCommits(t, s, tok, 5)
|
||||
|
||||
page1, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2&page=1", tok, 200)
|
||||
page2, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2&page=2", tok, 200)
|
||||
page3, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2&page=3", tok, 200)
|
||||
page1, _ := apiList[types.GistCommit](t, s, "/api/gists/"+id+"/commits?per_page=2&page=1", tok, 200)
|
||||
page2, _ := apiList[types.GistCommit](t, s, "/api/gists/"+id+"/commits?per_page=2&page=2", tok, 200)
|
||||
page3, _ := apiList[types.GistCommit](t, s, "/api/gists/"+id+"/commits?per_page=2&page=3", tok, 200)
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, c := range append(append(page1, page2...), page3...) {
|
||||
@@ -215,10 +215,10 @@ func TestListCommits_MultipleCommits_AfterPatch(t *testing.T) {
|
||||
})
|
||||
|
||||
// PATCH the content → second commit.
|
||||
s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok,
|
||||
s.APIRequest(t, "PATCH", "/api/gists/"+id, tok,
|
||||
`{"files": {"a.txt": {"content": "alpha v2"}}}`, 200)
|
||||
|
||||
commits, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits", tok, 200)
|
||||
commits, _ := apiList[types.GistCommit](t, s, "/api/gists/"+id+"/commits", tok, 200)
|
||||
|
||||
require.Len(t, commits, 2, "create + PATCH = two commits")
|
||||
// git log is newest-first.
|
||||
|
||||
@@ -23,7 +23,7 @@ func strOrEmpty(p *string) string {
|
||||
return *p
|
||||
}
|
||||
|
||||
// CreateGist handles POST /api/v1/gists.
|
||||
// CreateGist handles POST /api/gists.
|
||||
// The DTO is built the same way ProcessCreate builds its form DTO - entries
|
||||
// without `content` are skipped, empty filenames become "gistfileN.txt" -
|
||||
// and is then run through ctx.Validate so the API and the web form share
|
||||
@@ -115,11 +115,11 @@ func CreateGist(ctx *context.Context) error {
|
||||
if err != nil {
|
||||
return ctx.ErrorJson(500, "failed to serialize gist", err)
|
||||
}
|
||||
ctx.Response().Header().Set("Location", baseURL+"/api/v1/gists/"+saved.Uuid)
|
||||
ctx.Response().Header().Set("Location", baseURL+"/api/gists/"+saved.Uuid)
|
||||
return ctx.JSON(201, resp)
|
||||
}
|
||||
|
||||
// UpdateGist handles PATCH /api/v1/gists/:uuid.
|
||||
// UpdateGist handles PATCH /api/gists/:uuid.
|
||||
// Only fields present in the body are touched. Files not mentioned in `files`
|
||||
// stay unchanged. A file entry
|
||||
// can:
|
||||
@@ -205,7 +205,7 @@ func UpdateGist(ctx *context.Context) error {
|
||||
return ctx.JSON(200, resp)
|
||||
}
|
||||
|
||||
// DeleteGist handles DELETE /api/v1/gists/:uuid.
|
||||
// DeleteGist handles DELETE /api/gists/:uuid.
|
||||
// Owner-only - the route's apiScope(ScopeGist, ReadWritePermission) middleware
|
||||
// enforces the token scope before we get here, so we just confirm ownership and
|
||||
// drop the repo + row. Returns 204 No Content on success.
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestUpdateGist_ChangeRenameDelete(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
_, raw := s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok, patch, 200)
|
||||
_, raw := s.APIRequest(t, "PATCH", "/api/gists/"+id, tok, patch, 200)
|
||||
var resp fullGist
|
||||
require.NoError(t, json.Unmarshal(raw, &resp), "response: %s", string(raw))
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestUpdateGist_VisibilityChange(t *testing.T) {
|
||||
s, tok := setupCreateGist(t)
|
||||
id := createSeedGist(t, s, tok)
|
||||
|
||||
_, raw := s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok,
|
||||
_, raw := s.APIRequest(t, "PATCH", "/api/gists/"+id, tok,
|
||||
`{"visibility": "private"}`, 200)
|
||||
var resp fullGist
|
||||
require.NoError(t, json.Unmarshal(raw, &resp))
|
||||
@@ -118,7 +118,7 @@ func TestUpdateGist_TitleChange(t *testing.T) {
|
||||
s, tok := setupCreateGist(t)
|
||||
id := createSeedGist(t, s, tok)
|
||||
|
||||
_, raw := s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok,
|
||||
_, raw := s.APIRequest(t, "PATCH", "/api/gists/"+id, tok,
|
||||
`{"title": "renamed-title"}`, 200)
|
||||
var resp fullGist
|
||||
require.NoError(t, json.Unmarshal(raw, &resp))
|
||||
@@ -136,7 +136,7 @@ func TestUpdateGist_DescriptionChange(t *testing.T) {
|
||||
s, tok := setupCreateGist(t)
|
||||
id := createSeedGist(t, s, tok)
|
||||
|
||||
_, raw := s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok,
|
||||
_, raw := s.APIRequest(t, "PATCH", "/api/gists/"+id, tok,
|
||||
`{"description": "updated-description"}`, 200)
|
||||
var resp fullGist
|
||||
require.NoError(t, json.Unmarshal(raw, &resp))
|
||||
@@ -187,7 +187,7 @@ func TestUpdateGist_NoAccess(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s.APIRequest(t, "PATCH", "/api/v1/gists/"+c.uuid, c.tok, body, c.want)
|
||||
s.APIRequest(t, "PATCH", "/api/gists/"+c.uuid, c.tok, body, c.want)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ func TestDeleteGist_NoAccess(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s.APIRequest(t, "DELETE", "/api/v1/gists/"+c.uuid, c.tok, nil, c.want)
|
||||
s.APIRequest(t, "DELETE", "/api/gists/"+c.uuid, c.tok, nil, c.want)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -265,9 +265,9 @@ func TestUpdateGist_EmptyBody_422(t *testing.T) {
|
||||
s, tok := setupCreateGist(t)
|
||||
id := createSeedGist(t, s, tok)
|
||||
|
||||
s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok, `{}`, 422)
|
||||
s.APIRequest(t, "PATCH", "/api/gists/"+id, tok, `{}`, 422)
|
||||
// Same when only an empty files map is supplied - no actual change.
|
||||
s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok, `{"files": {}}`, 422)
|
||||
s.APIRequest(t, "PATCH", "/api/gists/"+id, tok, `{"files": {}}`, 422)
|
||||
}
|
||||
|
||||
func TestCreateGist_NoAuth(t *testing.T) {
|
||||
@@ -276,7 +276,7 @@ func TestCreateGist_NoAuth(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"files": fileMap{"test.txt": {"content": "hello"}},
|
||||
}
|
||||
s.APIRequest(t, "POST", "/api/v1/gists", "", body, 401)
|
||||
s.APIRequest(t, "POST", "/api/gists", "", body, 401)
|
||||
|
||||
count, err := db.CountAll(db.Gist{})
|
||||
require.NoError(t, err)
|
||||
@@ -516,7 +516,7 @@ func TestCreateGist(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w, body := s.APIRequest(t, "POST", "/api/v1/gists", tok, tt.body, tt.expectedCode)
|
||||
w, body := s.APIRequest(t, "POST", "/api/gists", tok, tt.body, tt.expectedCode)
|
||||
|
||||
if !tt.expectGistCreated {
|
||||
return
|
||||
@@ -533,7 +533,7 @@ func TestCreateGist(t *testing.T) {
|
||||
|
||||
// Location header: required on 201.
|
||||
require.NotEmpty(t, w.Header().Get("Location"), "Location header missing")
|
||||
require.Contains(t, w.Header().Get("Location"), "/api/v1/gists/"+resp.ID)
|
||||
require.Contains(t, w.Header().Get("Location"), "/api/gists/"+resp.ID)
|
||||
|
||||
// Files map keyed by filename.
|
||||
require.Len(t, resp.Files, len(tt.expectedFilenames), "file count mismatch")
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
|
||||
)
|
||||
|
||||
// ListForkedGists handles GET /api/v1/gists/forked.
|
||||
// ListForkedGists handles GET /api/gists/forked.
|
||||
// Lists gists the authenticated user has forked. Auth is mandatory (the
|
||||
// route uses apiRequireAuth) but the gist:read scope is soft-checked here
|
||||
// so a token without it degrades to the public subset of forked gists
|
||||
@@ -52,7 +52,7 @@ func ListForkedGists(ctx *context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ListForks handles GET /api/v1/gists/:uuid/forks.
|
||||
// ListForks handles GET /api/gists/:uuid/forks.
|
||||
// Returns the gists that fork
|
||||
// the targeted gist as a list of GistSimple. Same visibility rules as
|
||||
// /:uuid (and /:uuid/commits) - public/unlisted readable by anyone,
|
||||
@@ -101,7 +101,7 @@ func ListForks(ctx *context.Context) error {
|
||||
return ctx.JSON(200, out)
|
||||
}
|
||||
|
||||
// ForkGist handles POST /api/v1/gists/:uuid/forks.
|
||||
// ForkGist handles POST /api/gists/:uuid/forks.
|
||||
// The authenticated caller
|
||||
// gets a new gist owned by them whose content is a clone of the parent,
|
||||
// with `forked_id` pointing back. Visibility (public/unlisted/private) is
|
||||
@@ -141,7 +141,7 @@ func ForkGist(ctx *context.Context) error {
|
||||
return ctx.ErrorJson(500, "failed to reload existing fork", err)
|
||||
}
|
||||
baseURL := apiBaseURL(ctx)
|
||||
ctx.Response().Header().Set("Location", baseURL+"/api/v1/gists/"+saved.Uuid)
|
||||
ctx.Response().Header().Set("Location", baseURL+"/api/gists/"+saved.Uuid)
|
||||
return ctx.JSON(200, saved.ToAPISimple(baseURL))
|
||||
}
|
||||
|
||||
@@ -180,6 +180,6 @@ func ForkGist(ctx *context.Context) error {
|
||||
|
||||
baseURL := apiBaseURL(ctx)
|
||||
resp := saved.ToAPISimple(baseURL)
|
||||
ctx.Response().Header().Set("Location", baseURL+"/api/v1/gists/"+saved.Uuid)
|
||||
ctx.Response().Header().Set("Location", baseURL+"/api/gists/"+saved.Uuid)
|
||||
return ctx.JSON(201, resp)
|
||||
}
|
||||
|
||||
@@ -72,14 +72,14 @@ func TestListForks_VisibilityAccess(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s.APIRequest(t, "GET", "/api/v1/gists/"+c.uuid+"/forks", c.tok, nil, c.want)
|
||||
s.APIRequest(t, "GET", "/api/gists/"+c.uuid+"/forks", c.tok, nil, c.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForks_NotFound(t *testing.T) {
|
||||
s := setupGetGist(t)
|
||||
s.APIRequest(t, "GET", "/api/v1/gists/does-not-exist/forks", "", nil, 404)
|
||||
s.APIRequest(t, "GET", "/api/gists/does-not-exist/forks", "", nil, 404)
|
||||
}
|
||||
|
||||
// --- Visibility filter on the forks list itself ---
|
||||
@@ -142,7 +142,7 @@ func idSetSimple(arr []types.GistSimple) map[string]bool {
|
||||
func TestListForks_Anonymous_OnlyPublicForks(t *testing.T) {
|
||||
f := setupForksVisibility(t)
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/"+f.parent.Uuid+"/forks?per_page=20", "", 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists/"+f.parent.Uuid+"/forks?per_page=20", "", 200)
|
||||
|
||||
ids := idSetSimple(arr)
|
||||
require.True(t, ids[f.aliceFork.Uuid], "alice's PUBLIC fork must appear")
|
||||
@@ -160,7 +160,7 @@ func TestListForks_Anonymous_OnlyPublicForks(t *testing.T) {
|
||||
func TestListForks_AuthenticatedSeesOwnUnlistedFork(t *testing.T) {
|
||||
f := setupForksVisibility(t)
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/"+f.parent.Uuid+"/forks?per_page=20", f.bobTok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists/"+f.parent.Uuid+"/forks?per_page=20", f.bobTok, 200)
|
||||
|
||||
ids := idSetSimple(arr)
|
||||
require.True(t, ids[f.aliceFork.Uuid], "alice's PUBLIC fork must appear")
|
||||
@@ -173,7 +173,7 @@ func TestListForks_AuthenticatedSeesOwnUnlistedFork(t *testing.T) {
|
||||
func TestListForks_AuthenticatedSeesOwnPrivateFork(t *testing.T) {
|
||||
f := setupForksVisibility(t)
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/"+f.parent.Uuid+"/forks?per_page=20", f.charlieTok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists/"+f.parent.Uuid+"/forks?per_page=20", f.charlieTok, 200)
|
||||
|
||||
ids := idSetSimple(arr)
|
||||
require.True(t, ids[f.aliceFork.Uuid], "alice's PUBLIC fork must appear")
|
||||
@@ -187,7 +187,7 @@ func TestListForks_AuthenticatedSeesOwnPrivateFork(t *testing.T) {
|
||||
func TestListForks_ScopedThirdParty_OnlyPublic(t *testing.T) {
|
||||
f := setupForksVisibility(t)
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/"+f.parent.Uuid+"/forks?per_page=20", f.aliceTok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists/"+f.parent.Uuid+"/forks?per_page=20", f.aliceTok, 200)
|
||||
|
||||
ids := idSetSimple(arr)
|
||||
require.True(t, ids[f.aliceFork.Uuid], "alice's PUBLIC fork must appear")
|
||||
@@ -196,7 +196,7 @@ func TestListForks_ScopedThirdParty_OnlyPublic(t *testing.T) {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /api/v1/gists/forked - ListForkedGists (the caller's forks)
|
||||
// GET /api/gists/forked - ListForkedGists (the caller's forks)
|
||||
// =========================================================================
|
||||
|
||||
// forkedListFixture: parent_owner owns a public parent gist; the caller
|
||||
@@ -237,7 +237,7 @@ func setupForkedList(t *testing.T) *forkedListFixture {
|
||||
|
||||
func TestListForkedGists_NoAuth(t *testing.T) {
|
||||
s := setupGetGist(t)
|
||||
s.APIRequest(t, "GET", "/api/v1/gists/forked", "", nil, 401)
|
||||
s.APIRequest(t, "GET", "/api/gists/forked", "", nil, 401)
|
||||
}
|
||||
|
||||
func TestListForkedGists_EmptyWhenNoForks(t *testing.T) {
|
||||
@@ -249,7 +249,7 @@ func TestListForkedGists_EmptyWhenNoForks(t *testing.T) {
|
||||
|
||||
tok := apiTokenFor(t, s, "thomas", db.ReadPermission)
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, s, "/api/v1/gists/forked", tok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, s, "/api/gists/forked", tok, 200)
|
||||
require.Empty(t, arr)
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ func TestListForkedGists_ReturnsOnlyCallersForks(t *testing.T) {
|
||||
f := setupForkedList(t)
|
||||
callerTok := apiTokenFor(t, f.s, "caller", db.ReadPermission)
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/forked?per_page=20", callerTok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists/forked?per_page=20", callerTok, 200)
|
||||
|
||||
ids := idSetSimple(arr)
|
||||
require.True(t, ids[f.callerFork.Uuid], "caller's fork must appear")
|
||||
@@ -275,7 +275,7 @@ func TestListForkedGists_TokenWithGistRead_IncludesOwnPrivateFork(t *testing.T)
|
||||
setVisibility(t, f.callerFork, db.PrivateVisibility)
|
||||
callerTok := apiTokenFor(t, f.s, "caller", db.ReadPermission)
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/forked?per_page=20", callerTok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists/forked?per_page=20", callerTok, 200)
|
||||
|
||||
ids := idSetSimple(arr)
|
||||
require.True(t, ids[f.callerFork.Uuid],
|
||||
@@ -290,7 +290,7 @@ func TestListForkedGists_TokenWithoutGistRead_OnlyPublicForks(t *testing.T) {
|
||||
setVisibility(t, f.callerFork, db.UnlistedVisibility)
|
||||
noScopeTok := apiTokenFor(t, f.s, "caller", db.NoPermission)
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/forked?per_page=20", noScopeTok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists/forked?per_page=20", noScopeTok, 200)
|
||||
|
||||
ids := idSetSimple(arr)
|
||||
require.False(t, ids[f.callerFork.Uuid],
|
||||
@@ -308,7 +308,7 @@ func TestListForks_Shape(t *testing.T) {
|
||||
_, parent, parentUser, parentIdent := s.CreateGistAs(t, "owner", "0")
|
||||
fork := forkAs(t, s, "other", parentUser, parentIdent)
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, s, "/api/v1/gists/"+parent.Uuid+"/forks", "", 200)
|
||||
arr, _ := apiList[types.GistSimple](t, s, "/api/gists/"+parent.Uuid+"/forks", "", 200)
|
||||
|
||||
require.Len(t, arr, 1)
|
||||
got := arr[0]
|
||||
@@ -320,7 +320,7 @@ func TestListForks_Shape(t *testing.T) {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// POST /api/v1/gists/:uuid/forks
|
||||
// POST /api/gists/:uuid/forks
|
||||
// =========================================================================
|
||||
|
||||
// --- Auth / scope ---
|
||||
@@ -330,7 +330,7 @@ func TestForkGist_NoAuth(t *testing.T) {
|
||||
_, parent, _, _ := s.CreateGistAs(t, "owner", "0")
|
||||
|
||||
// apiRequireAuth on the route → 401 before the handler runs.
|
||||
s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", "", nil, 401)
|
||||
s.APIRequest(t, "POST", "/api/gists/"+parent.Uuid+"/forks", "", nil, 401)
|
||||
}
|
||||
|
||||
func TestForkGist_TokenWithoutWriteScope_403(t *testing.T) {
|
||||
@@ -339,7 +339,7 @@ func TestForkGist_TokenWithoutWriteScope_403(t *testing.T) {
|
||||
|
||||
// Read-only token can read but can't fork (creates a new gist).
|
||||
roTok := apiTokenFor(t, s, "other", db.ReadPermission)
|
||||
s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", roTok, nil, 403)
|
||||
s.APIRequest(t, "POST", "/api/gists/"+parent.Uuid+"/forks", roTok, nil, 403)
|
||||
}
|
||||
|
||||
// --- Visibility / access matrix on the parent ---
|
||||
@@ -383,7 +383,7 @@ func TestForkGist_VisibilityAccess(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s.APIRequest(t, "POST", "/api/v1/gists/"+c.uuid+"/forks", c.tok, nil, c.want)
|
||||
s.APIRequest(t, "POST", "/api/gists/"+c.uuid+"/forks", c.tok, nil, c.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -395,7 +395,7 @@ func TestForkGist_Success_ResponseAndState(t *testing.T) {
|
||||
_, parent, _, _ := s.CreateGistAs(t, "owner", "0")
|
||||
otherTok := apiTokenFor(t, s, "other", db.ReadWritePermission)
|
||||
|
||||
w, body := s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", otherTok, nil, 201)
|
||||
w, body := s.APIRequest(t, "POST", "/api/gists/"+parent.Uuid+"/forks", otherTok, nil, 201)
|
||||
|
||||
var resp types.GistSimple
|
||||
require.NoError(t, json.Unmarshal(body, &resp), "body: %s", string(body))
|
||||
@@ -411,7 +411,7 @@ func TestForkGist_Success_ResponseAndState(t *testing.T) {
|
||||
// Location header points to the new fork.
|
||||
loc := w.Header().Get("Location")
|
||||
require.NotEmpty(t, loc)
|
||||
require.Contains(t, loc, "/api/v1/gists/"+resp.ID)
|
||||
require.Contains(t, loc, "/api/gists/"+resp.ID)
|
||||
|
||||
// DB-side: fork row exists with ForkedID, parent's NbForks bumped.
|
||||
fork, err := db.GetGistByUUID(resp.ID)
|
||||
@@ -430,7 +430,7 @@ func TestForkGist_VisibilityInheritedFromUnlistedParent(t *testing.T) {
|
||||
_, parent, _, _ := s.CreateGistAs(t, "owner", "1") // unlisted
|
||||
otherTok := apiTokenFor(t, s, "other", db.ReadWritePermission)
|
||||
|
||||
_, body := s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", otherTok, nil, 201)
|
||||
_, body := s.APIRequest(t, "POST", "/api/gists/"+parent.Uuid+"/forks", otherTok, nil, 201)
|
||||
var resp types.GistSimple
|
||||
require.NoError(t, json.Unmarshal(body, &resp))
|
||||
require.Equal(t, "unlisted", resp.Visibility)
|
||||
@@ -448,17 +448,17 @@ func TestForkGist_AlreadyForked_200(t *testing.T) {
|
||||
otherTok := apiTokenFor(t, s, "other", db.ReadWritePermission)
|
||||
|
||||
// First fork → 201.
|
||||
_, firstBody := s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", otherTok, nil, 201)
|
||||
_, firstBody := s.APIRequest(t, "POST", "/api/gists/"+parent.Uuid+"/forks", otherTok, nil, 201)
|
||||
var first types.GistSimple
|
||||
require.NoError(t, json.Unmarshal(firstBody, &first))
|
||||
|
||||
// Second attempt → 200 with the existing fork echoed back, Location →
|
||||
// existing fork.
|
||||
w, secondBody := s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", otherTok, nil, 200)
|
||||
w, secondBody := s.APIRequest(t, "POST", "/api/gists/"+parent.Uuid+"/forks", otherTok, nil, 200)
|
||||
var second types.GistSimple
|
||||
require.NoError(t, json.Unmarshal(secondBody, &second))
|
||||
require.Equal(t, first.ID, second.ID, "the existing fork must be returned, not a new one")
|
||||
require.Contains(t, w.Header().Get("Location"), "/api/v1/gists/"+first.ID,
|
||||
require.Contains(t, w.Header().Get("Location"), "/api/gists/"+first.ID,
|
||||
"Location must point at the existing fork")
|
||||
|
||||
// And the parent's NbForks must not double-count.
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
// ListLikedGists handles GET /api/v1/gists/liked.
|
||||
// ListLikedGists handles GET /api/gists/liked.
|
||||
// Lists gists the authenticated user has liked. Auth is mandatory (the route
|
||||
// uses apiRequireAuth) but the
|
||||
// gist:read scope is soft-checked here so a token without it degrades to the
|
||||
@@ -44,7 +44,7 @@ func ListLikedGists(ctx *context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleLike handles PUT /api/v1/gists/:uuid/like.
|
||||
// ToggleLike handles PUT /api/gists/:uuid/like.
|
||||
// Idempotent toggle: if the authenticated user has liked the gist, it
|
||||
// removes the like; otherwise it adds one. Either way the response is 204
|
||||
// No Content. 404 when the gist isn't visible to the caller (same
|
||||
@@ -71,7 +71,7 @@ func ToggleLike(ctx *context.Context) error {
|
||||
return ctx.NoContent(204)
|
||||
}
|
||||
|
||||
// CheckLike handles GET /api/v1/gists/:uuid/like.
|
||||
// CheckLike handles GET /api/gists/:uuid/like.
|
||||
// Returns 204 No Content if the authenticated user has liked the gist, 404
|
||||
// otherwise. The gist's visibility is enforced first via lookupGistByUUID -
|
||||
// a hidden private gist returns 404 just like the rest of the API, without
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestListLikedGists_NoAuth(t *testing.T) {
|
||||
s.Logout()
|
||||
|
||||
// apiRequireAuth on the route rejects anonymous callers.
|
||||
s.APIRequest(t, "GET", "/api/v1/gists/liked", "", nil, 401)
|
||||
s.APIRequest(t, "GET", "/api/gists/liked", "", nil, 401)
|
||||
}
|
||||
|
||||
func TestListLikedGists_EmptyWhenNoStars(t *testing.T) {
|
||||
@@ -77,7 +77,7 @@ func TestListLikedGists_EmptyWhenNoStars(t *testing.T) {
|
||||
tok := s.CreateAccessToken(t, "tok", db.ReadPermission, db.ReadPermission)
|
||||
s.Logout()
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, s, "/api/v1/gists/liked", tok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, s, "/api/gists/liked", tok, 200)
|
||||
require.Empty(t, arr)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestListLikedGists_TokenWithGistRead_AllAllowed(t *testing.T) {
|
||||
tok := f.s.CreateAccessToken(t, "read", db.ReadPermission, db.ReadPermission)
|
||||
f.s.Logout()
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/liked?per_page=20", tok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists/liked?per_page=20", tok, 200)
|
||||
|
||||
ids := idSet(arr)
|
||||
require.True(t, ids[f.callerPub.Uuid], "caller's own PUBLIC liked gist visible")
|
||||
@@ -104,7 +104,7 @@ func TestListLikedGists_TokenWithGistRead_AllAllowed(t *testing.T) {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /api/v1/gists/:uuid/like - CheckLike
|
||||
// GET /api/gists/:uuid/like - CheckLike
|
||||
// =========================================================================
|
||||
|
||||
// likeGist directly inserts a like row for `username` on `g`, avoiding the
|
||||
@@ -122,7 +122,7 @@ func TestCheckLike_NoAuth(t *testing.T) {
|
||||
s := setupGetGist(t)
|
||||
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
|
||||
|
||||
s.APIRequest(t, "GET", "/api/v1/gists/"+gist.Uuid+"/like", "", nil, 401)
|
||||
s.APIRequest(t, "GET", "/api/gists/"+gist.Uuid+"/like", "", nil, 401)
|
||||
}
|
||||
|
||||
// TestCheckLike_StatusCodes is the headline matrix: combinations of (gist
|
||||
@@ -175,7 +175,7 @@ func TestCheckLike_StatusCodes(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s.APIRequest(t, "GET", "/api/v1/gists/"+c.uuid+"/like", c.tok, nil, c.want)
|
||||
s.APIRequest(t, "GET", "/api/gists/"+c.uuid+"/like", c.tok, nil, c.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -188,19 +188,19 @@ func TestCheckLike_204HasEmptyBody(t *testing.T) {
|
||||
likeGist(t, gist, "other")
|
||||
otherTok := apiTokenFor(t, s, "other", db.ReadPermission)
|
||||
|
||||
_, body := s.APIRequest(t, "GET", "/api/v1/gists/"+gist.Uuid+"/like", otherTok, nil, 204)
|
||||
_, body := s.APIRequest(t, "GET", "/api/gists/"+gist.Uuid+"/like", otherTok, nil, 204)
|
||||
require.Empty(t, body, "204 No Content responses must carry an empty body")
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PUT /api/v1/gists/:uuid/like - ToggleLike
|
||||
// PUT /api/gists/:uuid/like - ToggleLike
|
||||
// =========================================================================
|
||||
|
||||
func TestToggleLike_NoAuth(t *testing.T) {
|
||||
s := setupGetGist(t)
|
||||
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
|
||||
|
||||
s.APIRequest(t, "PUT", "/api/v1/gists/"+gist.Uuid+"/like", "", nil, 401)
|
||||
s.APIRequest(t, "PUT", "/api/gists/"+gist.Uuid+"/like", "", nil, 401)
|
||||
}
|
||||
|
||||
// TestToggleLike_LikesIfUnliked - first PUT on a never-liked gist adds the
|
||||
@@ -210,7 +210,7 @@ func TestToggleLike_LikesIfUnliked(t *testing.T) {
|
||||
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
|
||||
otherTok := likeTokenFor(t, s, "other")
|
||||
|
||||
_, body := s.APIRequest(t, "PUT", "/api/v1/gists/"+gist.Uuid+"/like", otherTok, nil, 204)
|
||||
_, body := s.APIRequest(t, "PUT", "/api/gists/"+gist.Uuid+"/like", otherTok, nil, 204)
|
||||
require.Empty(t, body, "204 must have empty body")
|
||||
|
||||
reloaded, err := db.GetGistByUUID(gist.Uuid)
|
||||
@@ -237,7 +237,7 @@ func TestToggleLike_UnlikesIfLiked(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, reloaded.NbLikes, "fixture precondition: NbLikes=1 after likeGist")
|
||||
|
||||
s.APIRequest(t, "PUT", "/api/v1/gists/"+gist.Uuid+"/like", otherTok, nil, 204)
|
||||
s.APIRequest(t, "PUT", "/api/gists/"+gist.Uuid+"/like", otherTok, nil, 204)
|
||||
|
||||
reloaded, err = db.GetGistByUUID(gist.Uuid)
|
||||
require.NoError(t, err)
|
||||
@@ -258,7 +258,7 @@ func TestToggleLike_FullCycle(t *testing.T) {
|
||||
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
|
||||
otherTok := likeTokenFor(t, s, "other")
|
||||
|
||||
url := "/api/v1/gists/" + gist.Uuid + "/like"
|
||||
url := "/api/gists/" + gist.Uuid + "/like"
|
||||
s.APIRequest(t, "PUT", url, otherTok, nil, 204) // like
|
||||
s.APIRequest(t, "PUT", url, otherTok, nil, 204) // unlike
|
||||
|
||||
@@ -278,7 +278,7 @@ func TestToggleLike_HiddenPrivateGist_404(t *testing.T) {
|
||||
_, gist, _, _ := s.CreateGistAs(t, "owner", "2") // private
|
||||
otherTok := likeTokenFor(t, s, "other")
|
||||
|
||||
s.APIRequest(t, "PUT", "/api/v1/gists/"+gist.Uuid+"/like", otherTok, nil, 404)
|
||||
s.APIRequest(t, "PUT", "/api/gists/"+gist.Uuid+"/like", otherTok, nil, 404)
|
||||
|
||||
// State unchanged.
|
||||
reloaded, err := db.GetGistByUUID(gist.Uuid)
|
||||
@@ -290,7 +290,7 @@ func TestToggleLike_NotFound(t *testing.T) {
|
||||
s := setupGetGist(t)
|
||||
otherTok := likeTokenFor(t, s, "other")
|
||||
|
||||
s.APIRequest(t, "PUT", "/api/v1/gists/does-not-exist/like", otherTok, nil, 404)
|
||||
s.APIRequest(t, "PUT", "/api/gists/does-not-exist/like", otherTok, nil, 404)
|
||||
}
|
||||
|
||||
// TestToggleLike_TokenWithoutUserWrite_403 - the toggle mutates the caller's
|
||||
@@ -305,7 +305,7 @@ func TestToggleLike_TokenWithoutUserWrite_403(t *testing.T) {
|
||||
tok := s.CreateAccessToken(t, "no-user-write", db.ReadPermission, db.ReadPermission)
|
||||
s.Logout()
|
||||
|
||||
s.APIRequest(t, "PUT", "/api/v1/gists/"+gist.Uuid+"/like", tok, nil, 403)
|
||||
s.APIRequest(t, "PUT", "/api/gists/"+gist.Uuid+"/like", tok, nil, 403)
|
||||
|
||||
reloaded, err := db.GetGistByUUID(gist.Uuid)
|
||||
require.NoError(t, err)
|
||||
@@ -321,7 +321,7 @@ func TestListLikedGists_TokenWithoutGistRead_OnlyPublic(t *testing.T) {
|
||||
tok := f.s.CreateAccessToken(t, "no-read", db.NoPermission, db.ReadPermission)
|
||||
f.s.Logout()
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/liked?per_page=20", tok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists/liked?per_page=20", tok, 200)
|
||||
|
||||
ids := idSet(arr)
|
||||
require.True(t, ids[f.callerPub.Uuid], "caller's own PUBLIC liked gist visible")
|
||||
|
||||
@@ -48,7 +48,7 @@ func listGistsCommon(ctx *context.Context, fetch gistFetcher) error {
|
||||
return ctx.JSON(200, out)
|
||||
}
|
||||
|
||||
// ListGists handles GET /api/v1/gists.
|
||||
// ListGists handles GET /api/gists.
|
||||
// Returns a JSON array; pagination is signaled via the X-* and Link response
|
||||
// headers. Scope-gated visibility of the caller's own gists; other users' gists
|
||||
// are never returned here (use /gists/public for that).
|
||||
@@ -92,7 +92,7 @@ func ListGists(ctx *context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ListPublicGists handles GET /api/v1/gists/public.
|
||||
// ListPublicGists handles GET /api/gists/public.
|
||||
// Returns only public gists regardless of the caller's auth state.
|
||||
func ListPublicGists(ctx *context.Context) error {
|
||||
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
|
||||
|
||||
@@ -63,7 +63,7 @@ func idSet(arr []types.GistSimple) map[string]bool {
|
||||
}
|
||||
|
||||
// TestListGists_GistObjectShape verifies every field of a types.GistSimple coming
|
||||
// back from /api/v1/gists is populated as expected. HttpGit and SshGit are
|
||||
// back from /api/gists is populated as expected. HttpGit and SshGit are
|
||||
// toggled on so the URL-bearing fields aren't empty; the test restores config
|
||||
// on cleanup.
|
||||
func TestListGists_GistObjectShape(t *testing.T) {
|
||||
@@ -83,7 +83,7 @@ func TestListGists_GistObjectShape(t *testing.T) {
|
||||
tok := s.CreateAccessToken(t, "shape", db.ReadPermission, db.ReadPermission)
|
||||
s.Logout()
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, s, "/api/v1/gists", tok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, s, "/api/gists", tok, 200)
|
||||
require.Len(t, arr, 1)
|
||||
got := arr[0]
|
||||
|
||||
@@ -119,7 +119,7 @@ func TestListGists_Anonymous_OnlyPublicFromEveryone(t *testing.T) {
|
||||
f := setupListVisibility(t)
|
||||
f.s.Logout()
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists?per_page=20", "", 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists?per_page=20", "", 200)
|
||||
|
||||
require.Len(t, arr, 2, "expected 2 gists, got %d", len(arr))
|
||||
ids := idSet(arr)
|
||||
@@ -146,7 +146,7 @@ func TestListGists_TokenWithoutGistRead_OnlyCallerPublic(t *testing.T) {
|
||||
tok := f.s.CreateAccessToken(t, "no-read", db.NoPermission, db.ReadPermission)
|
||||
f.s.Logout()
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists?per_page=20", tok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists?per_page=20", tok, 200)
|
||||
|
||||
require.Len(t, arr, 1, "expected 1 gist, got %d", len(arr))
|
||||
ids := idSet(arr)
|
||||
@@ -171,7 +171,7 @@ func TestListGists_TokenWithGistRead_AllOwnRegardlessOfVisibility(t *testing.T)
|
||||
tok := f.s.CreateAccessToken(t, "read", db.ReadPermission, db.ReadPermission)
|
||||
f.s.Logout()
|
||||
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists?per_page=20", tok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/gists?per_page=20", tok, 200)
|
||||
|
||||
require.Len(t, arr, 3, "expected 3 gists, got %d", len(arr))
|
||||
ids := idSet(arr)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
// RawFile handles GET /api/v1/gists/:uuid/files/:sha/:filename.
|
||||
// RawFile handles GET /api/gists/:uuid/files/:sha/:filename.
|
||||
// Returns the raw bytes of `filename` as committed at `sha`. Visibility
|
||||
// rules mirror GetGist/GetGistRevision:
|
||||
//
|
||||
|
||||
@@ -57,11 +57,11 @@ func likeTokenFor(t *testing.T, s *webtest.Server, user string) string {
|
||||
return tok
|
||||
}
|
||||
|
||||
// createGistViaAPI posts a body to /api/v1/gists and returns the resulting
|
||||
// createGistViaAPI posts a body to /api/gists and returns the resulting
|
||||
// gist's id. Useful for multi-file / large-content fixtures that can't be
|
||||
// built through the web form helper.
|
||||
func createGistViaAPI(t *testing.T, s *webtest.Server, tok string, body interface{}) string {
|
||||
_, raw := s.APIRequest(t, "POST", "/api/v1/gists", tok, body, 201)
|
||||
_, raw := s.APIRequest(t, "POST", "/api/gists", tok, body, 201)
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
@@ -110,14 +110,14 @@ func TestGetGist_VisibilityAccess(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s.APIRequest(t, "GET", "/api/v1/gists/"+c.uuid, c.tok, nil, c.want)
|
||||
s.APIRequest(t, "GET", "/api/gists/"+c.uuid, c.tok, nil, c.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGist_NotFound(t *testing.T) {
|
||||
s := setupGetGist(t)
|
||||
s.APIRequest(t, "GET", "/api/v1/gists/does-not-exist", "", nil, 404)
|
||||
s.APIRequest(t, "GET", "/api/gists/does-not-exist", "", nil, 404)
|
||||
}
|
||||
|
||||
// --- Response structure ---
|
||||
@@ -139,7 +139,7 @@ func TestGetGist_ResponseShape(t *testing.T) {
|
||||
|
||||
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
|
||||
|
||||
_, raw := s.APIRequest(t, "GET", "/api/v1/gists/"+gist.Uuid, "", nil, 200)
|
||||
_, raw := s.APIRequest(t, "GET", "/api/gists/"+gist.Uuid, "", nil, 200)
|
||||
var got fullGist
|
||||
require.NoError(t, json.Unmarshal(raw, &got))
|
||||
|
||||
@@ -209,7 +209,7 @@ func TestGetGist_ForkOfPopulated(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// GET the fork - fork_of must point at the parent.
|
||||
_, raw := s.APIRequest(t, "GET", "/api/v1/gists/"+forkGist.Uuid, "", nil, 200)
|
||||
_, raw := s.APIRequest(t, "GET", "/api/gists/"+forkGist.Uuid, "", nil, 200)
|
||||
var got fullGist
|
||||
require.NoError(t, json.Unmarshal(raw, &got))
|
||||
|
||||
@@ -218,7 +218,7 @@ func TestGetGist_ForkOfPopulated(t *testing.T) {
|
||||
require.Equal(t, "owner", got.ForkOf.Owner.Login, "fork_of.owner must be the parent's owner")
|
||||
|
||||
// Sanity: getting the parent reflects the bumped fork count and nil fork_of.
|
||||
_, parentRaw := s.APIRequest(t, "GET", "/api/v1/gists/"+parent.Uuid, "", nil, 200)
|
||||
_, parentRaw := s.APIRequest(t, "GET", "/api/gists/"+parent.Uuid, "", nil, 200)
|
||||
var parentGot fullGist
|
||||
require.NoError(t, json.Unmarshal(parentRaw, &parentGot))
|
||||
require.Nil(t, parentGot.ForkOf, "parent gist itself is not a fork")
|
||||
@@ -243,7 +243,7 @@ func TestGetGist_GistTruncatedWhenManyFiles(t *testing.T) {
|
||||
"files": files,
|
||||
})
|
||||
|
||||
_, raw := s.APIRequest(t, "GET", "/api/v1/gists/"+id, "", nil, 200)
|
||||
_, raw := s.APIRequest(t, "GET", "/api/gists/"+id, "", nil, 200)
|
||||
var got fullGist
|
||||
require.NoError(t, json.Unmarshal(raw, &got))
|
||||
|
||||
@@ -270,7 +270,7 @@ func TestGetGist_FileContentTruncatedWhenLarge(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
_, raw := s.APIRequest(t, "GET", "/api/v1/gists/"+id, "", nil, 200)
|
||||
_, raw := s.APIRequest(t, "GET", "/api/gists/"+id, "", nil, 200)
|
||||
var got fullGist
|
||||
require.NoError(t, json.Unmarshal(raw, &got))
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestApiAuth_MissingToken(t *testing.T) {
|
||||
s.Login(t, "thomas")
|
||||
|
||||
var body apiError
|
||||
s.APIRequestUnmarshal(t, "GET", "/api/v1/user", "", nil, &body, 401)
|
||||
s.APIRequestUnmarshal(t, "GET", "/api/user", "", nil, &body, 401)
|
||||
require.Equal(t, 401, body.Status)
|
||||
require.NotEmpty(t, body.Message)
|
||||
}
|
||||
@@ -39,10 +39,10 @@ func TestApiAuth_BearerAndTokenPrefix(t *testing.T) {
|
||||
tok := s.CreateAccessToken(t, "t", db.ReadPermission, db.ReadPermission)
|
||||
|
||||
// Bearer
|
||||
s.APIRequest(t, "GET", "/api/v1/user", tok, nil, 200)
|
||||
s.APIRequest(t, "GET", "/api/user", tok, nil, 200)
|
||||
|
||||
// Token prefix (legacy)
|
||||
req := newJSONReqWithAuth("GET", "/api/v1/user", "Token "+tok)
|
||||
req := newJSONReqWithAuth("GET", "/api/user", "Token "+tok)
|
||||
resp := s.RawRequest(t, req, 200)
|
||||
_ = json.NewDecoder(resp.Body).Decode(&map[string]interface{}{})
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestApiAuth_ExpiredToken(t *testing.T) {
|
||||
require.NoError(t, db.SaveAccessTokenForTest(all[0]))
|
||||
|
||||
var body apiError
|
||||
s.APIRequestUnmarshal(t, "GET", "/api/v1/user", tok, nil, &body, 401)
|
||||
s.APIRequestUnmarshal(t, "GET", "/api/user", tok, nil, &body, 401)
|
||||
require.Equal(t, 401, body.Status)
|
||||
require.NotEmpty(t, body.Message)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TestApiScope_GistWriteInsufficient(t *testing.T) {
|
||||
tok := s.CreateAccessToken(t, "no-write", db.ReadPermission, db.ReadPermission)
|
||||
|
||||
var body apiError
|
||||
s.APIRequestUnmarshal(t, "POST", "/api/v1/gists", tok, nil, &body, 403)
|
||||
s.APIRequestUnmarshal(t, "POST", "/api/gists", tok, nil, &body, 403)
|
||||
require.Equal(t, 403, body.Status)
|
||||
require.NotEmpty(t, body.Message)
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func TestApiScope_UserReadInsufficient(t *testing.T) {
|
||||
tok := s.CreateAccessToken(t, "no-user", db.ReadPermission, db.NoPermission)
|
||||
|
||||
var body apiError
|
||||
s.APIRequestUnmarshal(t, "GET", "/api/v1/user", tok, nil, &body, 403)
|
||||
s.APIRequestUnmarshal(t, "GET", "/api/user", tok, nil, &body, 403)
|
||||
require.Equal(t, 403, body.Status)
|
||||
require.NotEmpty(t, body.Message)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type GistFileInput struct {
|
||||
Filename *string `json:"filename,omitempty"`
|
||||
}
|
||||
|
||||
// GistInput is the unified request body for POST and PATCH /api/v1/gists.
|
||||
// GistInput is the unified request body for POST and PATCH /api/gists.
|
||||
// Every field is optional / nilable so handlers can tell "client didn't send
|
||||
// this" from "client explicitly set this", which is what the PATCH semantics
|
||||
// require: files from the previous version of the gist that aren't explicitly
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package types
|
||||
|
||||
// UpdateUserRequest is the PATCH /api/v1/user body. Pointer fields let the
|
||||
// UpdateUserRequest is the PATCH /api/user body. Pointer fields let the
|
||||
// handler distinguish "absent" from "explicit empty" so partial updates
|
||||
// don't accidentally clear other fields.
|
||||
//
|
||||
|
||||
@@ -19,13 +19,13 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
|
||||
)
|
||||
|
||||
// GetUser handles GET /api/v1/user.
|
||||
// GetUser handles GET /api/user.
|
||||
// Returns the authenticated caller's own user record.
|
||||
func GetUser(ctx *context.Context) error {
|
||||
return ctx.JSON(200, ctx.User.ToPrivateAPI())
|
||||
}
|
||||
|
||||
// UpdateUser handles PATCH /api/v1/user.
|
||||
// UpdateUser handles PATCH /api/user.
|
||||
// Updates the authenticated caller's username and/or email. Both fields are
|
||||
// optional - only fields present in the body are touched. Returns the
|
||||
// updated user on success (200), 422 on validation failures, 409 if the
|
||||
@@ -89,7 +89,7 @@ func UpdateUser(ctx *context.Context) error {
|
||||
return ctx.JSON(200, user.ToPrivateAPI())
|
||||
}
|
||||
|
||||
// GetUserByID handles GET /api/v1/user/:id.
|
||||
// GetUserByID handles GET /api/user/:id.
|
||||
// Looks up a user by numeric ID and returns the SimpleUser shape (no
|
||||
// private fields like email or admin flag). Anonymous-readable.
|
||||
func GetUserByID(ctx *context.Context) error {
|
||||
@@ -107,7 +107,7 @@ func GetUserByID(ctx *context.Context) error {
|
||||
return ctx.JSON(200, u.ToSimpleAPI())
|
||||
}
|
||||
|
||||
// GetUserByUsername handles GET /api/v1/users/:username.
|
||||
// GetUserByUsername handles GET /api/users/:username.
|
||||
// Looks up a user by username and returns the SimpleUser shape.
|
||||
// Anonymous-readable.
|
||||
func GetUserByUsername(ctx *context.Context) error {
|
||||
@@ -134,7 +134,7 @@ func userVisibleAs(ctx *context.Context, target *db.User) uint {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ListUserLikedGists handles GET /api/v1/users/:username/liked.
|
||||
// ListUserLikedGists handles GET /api/users/:username/liked.
|
||||
// Lists gists liked by :username, filtered to what the caller is allowed
|
||||
// to see. The target user's own private/unlisted liked gists only surface
|
||||
// when the caller IS that user AND holds gist:read.
|
||||
@@ -157,7 +157,7 @@ func ListUserLikedGists(ctx *context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ListUserForkedGists handles GET /api/v1/users/:username/forked.
|
||||
// ListUserForkedGists handles GET /api/users/:username/forked.
|
||||
// Lists gists forked by :username. Same caller-visibility rule as
|
||||
// ListUserLikedGists.
|
||||
func ListUserForkedGists(ctx *context.Context) error {
|
||||
@@ -179,7 +179,7 @@ func ListUserForkedGists(ctx *context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ListUserGists handles GET /api/v1/users/:username/gists.
|
||||
// ListUserGists handles GET /api/users/:username/gists.
|
||||
// Returns the named user's gists with visibility filtering:
|
||||
//
|
||||
// - Anonymous, or any caller other than the named user → only public
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestListUserGists_UnknownUser_404(t *testing.T) {
|
||||
t.Cleanup(func() { webtest.Teardown(t) })
|
||||
s.Register(t, "thomas")
|
||||
|
||||
s.APIRequest(t, "GET", "/api/v1/users/nobody/gists", "", nil, 404)
|
||||
s.APIRequest(t, "GET", "/api/users/nobody/gists", "", nil, 404)
|
||||
}
|
||||
|
||||
// userLikedFixture sets up a "target" user who has liked one public + one
|
||||
@@ -100,7 +100,7 @@ func TestListUserLikedGists_Visibility(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/users/target/liked?per_page=20", c.tok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/users/target/liked?per_page=20", c.tok, 200)
|
||||
|
||||
ids := idSetSimple(arr)
|
||||
require.Equal(t, c.seePub, ids[f.targetPub.Uuid], "PUBLIC visibility expectation")
|
||||
@@ -115,7 +115,7 @@ func TestListUserLikedGists_UnknownUser_404(t *testing.T) {
|
||||
t.Cleanup(func() { webtest.Teardown(t) })
|
||||
s.Register(t, "thomas")
|
||||
|
||||
s.APIRequest(t, "GET", "/api/v1/users/nobody/liked", "", nil, 404)
|
||||
s.APIRequest(t, "GET", "/api/users/nobody/liked", "", nil, 404)
|
||||
}
|
||||
|
||||
// userForkedFixture sets up parentowner → public parent. target forks the
|
||||
@@ -175,7 +175,7 @@ func TestListUserForkedGists_Visibility(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/users/target/forked?per_page=20", c.tok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/users/target/forked?per_page=20", c.tok, 200)
|
||||
|
||||
ids := idSetSimple(arr)
|
||||
require.Equal(t, c.seePub, ids[f.targetPub.Uuid], "PUBLIC fork visibility expectation")
|
||||
@@ -190,7 +190,7 @@ func TestListUserForkedGists_UnknownUser_404(t *testing.T) {
|
||||
t.Cleanup(func() { webtest.Teardown(t) })
|
||||
s.Register(t, "thomas")
|
||||
|
||||
s.APIRequest(t, "GET", "/api/v1/users/nobody/forked", "", nil, 404)
|
||||
s.APIRequest(t, "GET", "/api/users/nobody/forked", "", nil, 404)
|
||||
}
|
||||
|
||||
// TestListUserGists_Visibility - table-driven matrix of caller types ×
|
||||
@@ -222,7 +222,7 @@ func TestListUserGists_Visibility(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/users/target/gists?per_page=20", c.tok, 200)
|
||||
arr, _ := apiList[types.GistSimple](t, f.s, "/api/users/target/gists?per_page=20", c.tok, 200)
|
||||
|
||||
ids := idSetSimple(arr)
|
||||
require.Equal(t, c.seePub, ids[f.targetPub.Uuid], "PUBLIC visibility expectation")
|
||||
|
||||
@@ -69,8 +69,8 @@ func (s *Server) registerMiddlewares() {
|
||||
CookieHTTPOnly: true,
|
||||
CookieSameSite: http.SameSiteStrictMode,
|
||||
Skipper: func(ctx echo.Context) bool {
|
||||
// skip CSRF for /api/v1 (uses bearer tokens, not session cookies)
|
||||
if strings.HasPrefix(ctx.Request().URL.Path, "/api/v1/") {
|
||||
// skip CSRF for /api (uses bearer tokens, not session cookies)
|
||||
if strings.HasPrefix(ctx.Request().URL.Path, "/api/") {
|
||||
return true
|
||||
}
|
||||
/* skip CSRF for embeds */
|
||||
@@ -499,7 +499,7 @@ func setAllGistsMode(mode string) Middleware {
|
||||
}
|
||||
}
|
||||
|
||||
// apiBindAuth gates /api/v1 on the api.enabled config option and optionally
|
||||
// apiBindAuth gates /api on the api.enabled config option and optionally
|
||||
// resolves a caller identity from the Authorization header. A missing header is
|
||||
// allowed (the downstream handler/scope middleware decides whether anonymous
|
||||
// access is OK); a malformed/expired/unknown token is always rejected with 401.
|
||||
|
||||
@@ -105,7 +105,7 @@ func (s *Server) registerRoutes() {
|
||||
r.Any("/init/*", git.GitHttp, gistNewPushSoftInit)
|
||||
}
|
||||
|
||||
apiV1 := r.SubGroup("/api/v1")
|
||||
apiV1 := r.SubGroup("/api")
|
||||
{
|
||||
apiV1.Use(apiBindAuth)
|
||||
apiV1.GET("/gists", apiv1.ListGists)
|
||||
@@ -140,8 +140,8 @@ func (s *Server) registerRoutes() {
|
||||
apiV1.GET("/users/:username/forked", apiv1.ListUserForkedGists)
|
||||
apiV1.Any("", noRouteFoundApi)
|
||||
}
|
||||
r.GET("/api/v1/openapi.yaml", api.OpenAPISpec)
|
||||
r.Any("/api/v1/*", noRouteFoundApi)
|
||||
r.GET("/api/openapi.yaml", api.OpenAPISpec)
|
||||
r.Any("/api/*", noRouteFoundApi)
|
||||
|
||||
r.GET("/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all"))
|
||||
|
||||
|
||||
+1
-4
@@ -4,10 +4,7 @@
|
||||
"scripts": {
|
||||
"dev": "node_modules/.bin/vite -c public/vite.config.js",
|
||||
"build": "node_modules/.bin/vite -c public/vite.config.js build",
|
||||
"preview": "node_modules/.bin/vite -c public/vite.config.js preview",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
"preview": "node_modules/.bin/vite -c public/vite.config.js preview"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user