Beautiful docs website (#710)

Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
This commit is contained in:
Thomas
2026-06-08 03:15:24 +08:00
committed by GitHub
parent 3b8d947ad8
commit 31bc25e569
59 changed files with 5589 additions and 558 deletions
+6 -8
View File
@@ -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: |
+3
View File
@@ -0,0 +1,3 @@
node_modules
.vitepress/dist
.vitepress/cache
+227 -65
View File
@@ -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
})
+13
View File
@@ -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()
})
+441
View File
@@ -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(' &amp; ')
if (s.type === 'array') return typeHtml(s.items) + '[]'
if (s.type === 'null') return '<code>null</code>'
if (s.type === 'object' && s.additionalProperties)
return `map&lt;string, ${typeHtml(s.additionalProperties)}&gt;`
let t = s.type || 'object'
if (s.format) t += ` &lt;${s.format}&gt;`
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&lt;string, ${typeHtml(f.addl)}&gt;` : ''
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
}
}
-37
View File
@@ -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',
}
-101
View File
@@ -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>
+8 -3
View File
@@ -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>
+6 -1
View File
@@ -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
+45
View File
@@ -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>
+138
View File
@@ -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; }
}
+184 -7
View File
@@ -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
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
---
aside: false
pageClass: api-page
---
<script setup>
import { useData } from 'vitepress'
const { params } = useData()
</script>
<OpenApiOperation :id="params.id" />
+10
View File
@@ -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}` }
}))
}
}
+9
View File
@@ -0,0 +1,9 @@
---
aside: false
---
<script setup>
import { useData } from 'vitepress'
const { params } = useData()
</script>
<OpenApiSchema :name="params.name" />
+8
View File
@@ -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 } }))
}
}
+6
View File
@@ -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
View File
@@ -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.
---
+4
View File
@@ -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.
+4
View File
@@ -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" />
+3367
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://opengist.io/sitemap.xml
-196
View File
@@ -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>
```
+1 -1
View File
@@ -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 ?
+1 -1
View File
@@ -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 的发现功能。
+4 -4
View File
@@ -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)
+1 -1
View File
@@ -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")
}
+5 -5
View File
@@ -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)
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+4 -4
View File
@@ -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.
+11 -11
View File
@@ -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")
+5 -5
View File
@@ -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)
}
+23 -23
View File
@@ -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.
+3 -3
View File
@@ -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
+16 -16
View File
@@ -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")
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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:
//
+9 -9
View File
@@ -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.
//
+7 -7
View File
@@ -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
+6 -6
View File
@@ -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")
+3 -3
View File
@@ -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.
+3 -3
View File
@@ -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
View File
@@ -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": {