├── .dockerignore
├── .github
└── workflows
│ ├── build.yml
│ └── release.yml
├── .gitignore
├── Dockerfile
├── Makefile
├── README.md
├── cmd
└── post-receive
│ ├── main.go
│ └── utils.go
├── components
├── avatar.tsx
├── base.tsx
├── body.tsx
├── button.tsx
├── center.tsx
├── code.tsx
├── code
│ ├── gutter.tsx
│ ├── load.ts
│ ├── maps.ts
│ └── pre.tsx
├── commit
│ ├── commit.tsx
│ └── diff.tsx
├── divider.tsx
├── dropbox.tsx
├── dropdown.tsx
├── feed
│ ├── commit-event.tsx
│ └── create-repository-event.tsx
├── header.tsx
├── heading.tsx
├── image.tsx
├── input.tsx
├── issue
│ ├── comment.tsx
│ └── write.tsx
├── label.tsx
├── link.tsx
├── list.tsx
├── new
│ ├── button.tsx
│ ├── head.tsx
│ ├── organization.tsx
│ └── repository.tsx
├── profile
│ ├── org.tsx
│ ├── repos.tsx
│ └── user.tsx
├── progress.tsx
├── relative.tsx
├── repository
│ ├── branch.tsx
│ ├── empty.tsx
│ ├── layout.tsx
│ ├── path.tsx
│ └── tree.tsx
├── settings
│ ├── field.tsx
│ └── left.tsx
├── skeleton.tsx
├── split.tsx
├── svgs
│ ├── add.tsx
│ ├── arrow.tsx
│ ├── file.tsx
│ ├── folder.tsx
│ ├── git.tsx
│ ├── logo.tsx
│ ├── no-data.tsx
│ ├── no-events.tsx
│ ├── notfound.tsx
│ └── pencil.tsx
├── tabs.tsx
└── text.tsx
├── data
├── CascadiaCode.woff2
└── o2.ini
├── docker-compose.yml
├── go.mod
├── go.sum
├── main.go
├── package.json
├── pages
├── 404.tsx
├── _app.tsx
├── _document.tsx
├── feed.tsx
├── login.tsx
├── new.tsx
├── organization.tsx
├── register.tsx
├── repository
│ ├── blob.tsx
│ ├── commit.tsx
│ ├── commits.tsx
│ ├── issue.tsx
│ ├── issues.tsx
│ ├── new.tsx
│ ├── repository.tsx
│ ├── settings.tsx
│ └── tree.tsx
├── settings
│ ├── privacy.tsx
│ └── settings.tsx
└── user.tsx
├── pkg
├── auth
│ ├── is.go
│ ├── login.go
│ ├── middleware.go
│ ├── register.go
│ └── token.go
├── data
│ ├── base.go
│ ├── compose.go
│ └── with.go
├── git
│ ├── blob.go
│ ├── branch.go
│ ├── command.go
│ ├── commit.go
│ ├── commits.go
│ ├── hooks.go
│ ├── init.go
│ ├── path.go
│ ├── refs.go
│ ├── repository.go
│ └── tree.go
├── images
│ └── picture.go
├── log
│ └── log.go
├── middleware
│ ├── charset.go
│ ├── debug.go
│ ├── pex.go
│ ├── repo.go
│ ├── resource.go
│ └── scheme.go
├── models
│ ├── action.go
│ ├── base.go
│ ├── init.go
│ ├── issue.go
│ ├── issue_comment.go
│ ├── organization.go
│ ├── permission.go
│ ├── repository.go
│ └── user.go
├── pex
│ └── pex.go
├── render
│ ├── ogp.go
│ └── render.go
└── store
│ ├── config.go
│ ├── cwd.go
│ ├── database.go
│ ├── hooks.go
│ └── log.go
├── quercia.config.js
├── routes
├── datas
│ ├── blob.go
│ └── repository.go
├── git
│ ├── cache_headers.go
│ ├── get_service_type.go
│ ├── git_command.go
│ ├── internal_error.go
│ ├── packets.go
│ ├── refs.go
│ └── rpc.go
├── index.go
├── login.go
├── logout.go
├── new-org.go
├── new-repo.go
├── new.go
├── picture.go
├── profile.go
├── register.go
├── repository
│ ├── blob.go
│ ├── commit.go
│ ├── commits.go
│ ├── issue.go
│ ├── issues.go
│ ├── new.go
│ ├── repository.go
│ ├── settings.go
│ └── tree.go
├── settings
│ ├── privacy.go
│ └── settings.go
└── shared
│ └── 404.go
├── tsconfig.json
├── types
├── data.ts
├── index.d.ts
├── mdown.ts
├── repository.ts
├── theme.ts
├── time.ts
└── xtend.ts
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | screens
3 | data/repos
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 | on:
3 | push:
4 | tags:
5 | - 'v*'
6 |
7 | jobs:
8 | frontend:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v1
12 | - uses: volta-cli/action@v1
13 |
14 | - run: yarn install
15 | - run: yarn build
16 |
17 | - name: Upload quercia build artifacts
18 | uses: actions/upload-artifact@v1
19 | with:
20 | name: __quercia
21 | path: __quercia
22 |
23 | build:
24 | needs: frontend
25 | strategy:
26 | matrix:
27 | platform: [ubuntu-latest, macos-latest, windows-latest]
28 | runs-on: ${{ matrix.platform }}
29 | steps:
30 | - uses: actions/checkout@v2
31 | - uses: actions/setup-go@v2
32 |
33 | - name: Download previous quercia build
34 | uses: actions/download-artifact@v1
35 | with:
36 | name: __quercia
37 |
38 | - name: Build pkger asset file
39 | run: |
40 | go get -v -t -d ./...
41 | go get -d -v github.com/markbates/pkger@v0.16.0
42 | go get github.com/markbates/pkger/cmd/pkger
43 | rm -rf __quercia/*/server
44 | rm data/CascadiaCode.woff2
45 | pkger
46 |
47 | - name: Build the go binary
48 | run: go build -ldflags="-s -w" -o o2
49 |
50 | - name: Create Release
51 | id: create_release
52 | uses: actions/create-release@v1
53 | env:
54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55 | with:
56 | tag_name: ${{ github.ref }}
57 | release_name: Release ${{ github.ref }}
58 | draft: false
59 | prerelease: false
60 |
61 | - name: Upload Release Asset
62 | id: upload-release-asset
63 | uses: actions/upload-release-asset@v1
64 | env:
65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
66 | with:
67 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps
68 | asset_path: ./o2
69 | asset_name: o2-${{ matrix.platform }}
70 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __quercia
2 | node_modules
3 | bins
4 | data/repos/*
5 | data/pictures/*
6 | data/*.log
7 | pkged.go
8 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.14-alpine AS builder
2 |
3 | RUN apk update && apk add --no-cache git ca-certificates build-base && update-ca-certificates
4 |
5 | WORKDIR /app
6 |
7 | COPY . .
8 |
9 | RUN go mod download
10 | RUN go mod verify
11 | RUN go get -v -t -d ./... && \
12 | go get -d -v github.com/markbates/pkger@v0.16.0 && \
13 | go build -o /go/bin/pkger github.com/markbates/pkger/cmd/pkger && \
14 | rm -rf __quercia/*/server && \
15 | pkger
16 |
17 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -tags netgo -ldflags '-w -s' -o /go/bin/o2 *.go
18 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -tags netgo -ldflags '-w -s' -o /go/bin/o2-post-receive cmd/post-receive/*.go
19 |
20 | RUN mkdir -p /data/repos
21 |
22 | FROM alpine:latest
23 |
24 | RUN apk update && apk add --no-cache git
25 |
26 | WORKDIR /
27 | COPY --from=builder /data /data
28 | COPY --from=builder /go/bin/o2 /bin/o2
29 | COPY --from=builder /go/bin/o2-post-receive /bin/o2-post-receive
30 |
31 | ENTRYPOINT ["/bin/o2", "--debug"]
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | all: o2 cmds
2 |
3 | run: cmds
4 | PATH=$$PATH:$$PWD/bins go run main.go
5 |
6 | o2:
7 | go build main.go
8 |
9 | cmds:
10 | go build -o bins/o2-post-receive cmd/post-receive/*.go
11 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | o2 - self-hosted git web app
3 |
4 |
5 | > The project is still in early development, and while we try to provide the
6 | > best support you may encounter bugs and rough edges. Be sure to always report
7 | > anything you find so that we can improve your experience!
8 |
9 | ### Setting up
10 |
11 | There is currently no official documentation, but if you want to get a glimpse
12 | at what `o2` is capable of you can try running it via the CI built docker image.
13 | To get a quick start you can use this
14 | [`docker-compose.yml`](https://github.com/lucat1/o2/blob/master/docker-compose.yml)
15 | example file.
16 |
17 | ### screenshots
18 |
19 | 
20 | 
21 | 
22 | 
23 | 
24 | 
25 | 
26 |
--------------------------------------------------------------------------------
/cmd/post-receive/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "flag"
6 | "os"
7 | "path"
8 | "time"
9 |
10 | "github.com/lucat1/o2/pkg/log"
11 | "github.com/lucat1/o2/pkg/models"
12 | "github.com/lucat1/o2/pkg/store"
13 | )
14 |
15 | func init() {
16 | flag.Parse()
17 | logsPath := os.Getenv("LOGSPATH")
18 |
19 | // initialize the logger, store and database
20 | store.InitConfig()
21 | store.InitLogs()
22 |
23 | // store logs only in a file. Don't print anything to the client
24 | file, err := os.OpenFile(logsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
25 | if err != nil {
26 | log.Fatal().Err(err).Msg("Could not open log file (post-receive)")
27 | }
28 | log.Output(file)
29 |
30 | store.InitDatabase()
31 | }
32 |
33 | func main() {
34 | gitDir := os.Getenv("GIT_DIR")
35 | dir := path.Join(store.GetCwd(), gitDir)
36 |
37 | // read stdin data
38 | previous, next, ref := parseStdin()
39 |
40 | log.Info().
41 | Str("dir", dir).
42 | Strs("arguments", os.Args).
43 | Str("previous", previous).
44 | Str("next", next).
45 | Str("ref", ref).
46 | Msg("Called post-receive")
47 |
48 | // get commits
49 | repo := findRepository(dir)
50 | dbRepo := findDatabaseRepository(dir)
51 | commits := findCommits(repo, previous, next)
52 | log.Info().
53 | Strs("commits", commitsHashes(commits)).
54 | Msg("Found commits")
55 |
56 | raw, _ := json.Marshal(map[string]interface{}{
57 | "commits": commits.Commits,
58 | "more": commits.Next,
59 | })
60 | // push the action to the database
61 | event := models.Event{
62 | Time: time.Now(),
63 | Type: models.CommitEvent,
64 | Resource: dbRepo.UUID,
65 | Data: raw,
66 | }
67 |
68 | if err := event.Insert(); err != nil {
69 | log.Fatal().Err(err).Msg("Could not save event in the database")
70 | }
71 |
72 | log.Info().Msg("Successfully saved event in databases")
73 | }
74 |
--------------------------------------------------------------------------------
/cmd/post-receive/utils.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "os"
6 | "path"
7 |
8 | "github.com/lucat1/o2/pkg/git"
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/lucat1/o2/pkg/models"
11 | uuid "github.com/satori/go.uuid"
12 | )
13 |
14 | func parseStdin() (string, string, string) {
15 | scanner := bufio.NewScanner(os.Stdin)
16 | // scan every byte
17 | scanner.Split(bufio.ScanBytes)
18 |
19 | i := 0
20 | res := []string{"", "", ""}
21 |
22 | // split by spaces
23 | for scanner.Scan() {
24 | char := scanner.Text()
25 | if char == " " {
26 | i++
27 | continue
28 | }
29 |
30 | res[i] += char
31 | }
32 |
33 | if err := scanner.Err(); err != nil {
34 | log.Fatal().Err(err).Msg("Could not read input from stdin")
35 | }
36 |
37 | return res[0], res[1], res[2]
38 | }
39 |
40 | func findRepository(path string) *git.Repository {
41 | return &git.Repository{Path: path}
42 | }
43 |
44 | func findDatabaseRepository(dir string) models.Repository {
45 | rawUUID := path.Base(dir)
46 | id, err := uuid.FromString(rawUUID)
47 | if err != nil {
48 | log.Fatal().Err(err).Msg("Could not parse UUID")
49 | }
50 |
51 | repo, err := models.GetRepositoryByUUID(id)
52 | if err != nil {
53 | log.Fatal().
54 | Err(err).
55 | Str("uuid", id.String()).
56 | Msg("Error while querying the DB to find a repository")
57 | }
58 |
59 | return repo
60 | }
61 |
62 | func findCommits(repo *git.Repository, prev, next string) git.Commits {
63 | branch := prev + ".." + next
64 | // if the previos commit didn't exist (first push)
65 | // we can just log until the `next`
66 | if prev == "0000000000000000000000000000000000000000" {
67 | branch = next
68 | }
69 |
70 | commits, err := repo.Branch(branch).Commits(0, 10)
71 | if err != nil {
72 | log.Fatal().
73 | Err(err).
74 | Str("repo", repo.Path).
75 | Str("prev", prev).
76 | Str("next", next).
77 | Str("branch", branch).
78 | Msg("Could not find pushed commits")
79 | }
80 |
81 | return commits
82 | }
83 |
84 | func commitsHashes(commits git.Commits) (res []string) {
85 | for _, commit := range commits.Commits {
86 | res = append(res, commit.Commit)
87 | }
88 |
89 | return
90 | }
91 |
--------------------------------------------------------------------------------
/components/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 |
4 | import { navigate } from '@quercia/quercia'
5 |
6 | import Relative from './relative'
7 | import Dropdown from './dropdown'
8 | import Divider from './divider'
9 |
10 | import Image from './image'
11 | import { List, Item } from './list'
12 |
13 | import { LoggedUser } from '../types/data'
14 |
15 | const Avatar: React.FC = ({ picture, name }) => {
16 | const [open, setOpen] = React.useState(false)
17 | const go = React.useCallback((url: string) => {
18 | setOpen(false)
19 | navigate(url)
20 | }, [])
21 |
22 | return (
23 |
24 | setOpen(true)}
37 | onKeyUp={e => e.keyCode == 13 && setOpen(!open)}
38 | alt='Your profile picture'
39 | src={'/picture/' + picture}
40 | />
41 | setOpen(false)}
46 | >
47 |
48 | - go(`/${name}`)}>Your profile
49 |
50 | - go('/settings')}>Settings
51 |
52 | - go('/new')}>New
53 |
54 | - go('/logout')}>Logout
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | export default Avatar
62 |
--------------------------------------------------------------------------------
/components/base.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box, BoxProps, FlexProps } from 'rebass'
3 | import merge from '../types/xtend'
4 |
5 | const Base: React.FC = props => (
6 |
19 | )
20 |
21 | export default Base
22 |
--------------------------------------------------------------------------------
/components/body.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex, FlexProps } from 'rebass'
3 | import merge from '../types/xtend'
4 |
5 | const Body: React.FC = props => (
6 |
18 | )
19 |
20 | export default Body
21 |
--------------------------------------------------------------------------------
/components/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { variant } from 'styled-system'
3 | import { Button as RebassButton, ButtonProps } from 'rebass'
4 | import merge from '../types/xtend'
5 |
6 | const PrimaryButton: React.FC = props => (
7 |
50 | )
51 |
52 | const Button: React.FC = props => {
53 | const sx = variant({
54 | variants: {
55 | 'sm': {
56 | minWidth: '1.25rem',
57 | py: 1
58 | },
59 |
60 | 'md': {
61 | minWidth: '5.5rem'
62 | },
63 |
64 | 'secondary': {
65 | borderColor: 'bg.3',
66 | color: 'fg.5',
67 | bg: 'transparent'
68 | },
69 |
70 | 'md-secondary': {
71 | borderColor: 'bg.3',
72 | color: 'fg.5',
73 | bg: 'transparent',
74 | minWidth: '5.5rem'
75 | },
76 | 'sm-secondary': {
77 | minWidth: '1.25rem',
78 | borderColor: 'bg.3',
79 | color: 'fg.5',
80 | bg: 'transparent'
81 | }
82 | }
83 | })(props)
84 |
85 | return (
86 |
91 | )
92 | }
93 |
94 | export default Button
95 |
--------------------------------------------------------------------------------
/components/center.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex, FlexProps } from 'rebass'
3 | import merge from '../types/xtend'
4 |
5 | const Center: React.FC = props => (
6 |
17 | )
18 |
19 | export default Center
20 |
--------------------------------------------------------------------------------
/components/code.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import merge from '../types/xtend'
3 |
4 | import Text, { TextProps } from './text'
5 |
6 | const Code: React.FC = props => {
7 | return (
8 |
20 | )
21 | }
22 |
23 | export default Code
24 |
--------------------------------------------------------------------------------
/components/code/gutter.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import merge from '../../types/xtend'
3 |
4 | import { TextProps } from '../text'
5 | import Pre from './pre'
6 |
7 | const Gutter: React.FC = props => (
8 |
23 | )
24 |
25 | export default Gutter
26 |
--------------------------------------------------------------------------------
/components/code/load.ts:
--------------------------------------------------------------------------------
1 | import { reqScript } from '@quercia/quercia'
2 |
3 | import { aliases, dependencies } from './maps'
4 |
5 | const loaded: Set = new Set()
6 |
7 | export const lang = (l: string): string => aliases[l] || l
8 |
9 | const load = async (l: string) => {
10 | const language = lang(l)
11 | if (language == undefined || loaded.has(language)) {
12 | return
13 | }
14 |
15 | let deps: string | string[] = dependencies[language]
16 | if (!Array.isArray(deps)) {
17 | deps = [deps]
18 | }
19 |
20 | await Promise.all(deps.map(load))
21 |
22 | await reqScript(
23 | `https://unpkg.com/prismjs@1.20.0/components/prism-${language}.min.js`
24 | )
25 | loaded.add(language)
26 | }
27 |
28 | export default load
29 |
--------------------------------------------------------------------------------
/components/code/pre.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import merge from '../../types/xtend'
3 |
4 | import Text, { TextProps } from '../text'
5 |
6 | const Pre: React.FC = props => (
7 |
57 | )
58 |
59 | // `
60 |
61 | export default Pre
62 |
--------------------------------------------------------------------------------
/components/commit/commit.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 | import format from 'tinydate'
4 |
5 | import Container from '../base'
6 | import Image from '../image'
7 | import Link from '../link'
8 | import Text from '../text'
9 |
10 | import { Commit as ICommit } from '../../types/data'
11 |
12 | const Commit: React.FC<{ commit: ICommit; base: string }> = ({
13 | commit,
14 | base
15 | }) => (
16 |
25 |
26 |
31 |
41 |
42 |
53 |
54 | {commit?.subject}
55 |
56 |
57 |
58 |
59 | {commit?.author?.name}
60 |
61 |
62 | commited on
63 |
64 |
65 | {format('{DD} {MM} {YYYY}')(new Date(commit?.author?.date))}
66 |
67 |
68 |
69 |
70 |
71 |
72 | {commit?.abbrv_tree}
73 |
74 |
75 | )
76 |
77 | export default Commit
78 |
--------------------------------------------------------------------------------
/components/divider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box, BoxProps } from 'rebass'
3 | import merge from '../types/xtend'
4 |
5 | const Divider: React.FC = props => (
6 |
12 | )
13 |
14 | export default Divider
15 |
--------------------------------------------------------------------------------
/components/dropbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box, ButtonProps } from 'rebass'
3 | import merge from '../types/xtend'
4 |
5 | import Arrow from './svgs/arrow'
6 | import Button from './button'
7 |
8 | const Dropbox: React.FC = ({
9 | children,
10 | open,
11 | ...props
12 | }) => (
13 |
24 | )
25 |
26 | export default Dropbox
27 |
--------------------------------------------------------------------------------
/components/dropdown.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box, BoxProps } from 'rebass'
3 | import merge from '../types/xtend'
4 | import useOnClickOutside from 'use-onclickoutside'
5 |
6 | const Dropdown: React.FC void
9 | }> = ({ open, onClose, ...props }) => {
10 | const ref = React.useRef()
11 | useOnClickOutside(ref, onClose)
12 |
13 | return (
14 |
43 | )
44 | }
45 |
46 | export default Dropdown
47 |
--------------------------------------------------------------------------------
/components/feed/commit-event.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 | import format from 'tinydate'
4 |
5 | import Heading from '../heading'
6 | import Link from '../link'
7 | import Text from '../text'
8 | import Commit from '../commit/commit'
9 |
10 | import { CommitEvent as Event } from '../../types/data'
11 |
12 | const CommitEvent: React.FC<{ event: Event }> = ({ event }) => {
13 | const base = '/' + event.owner + '/' + event.name
14 | return (
15 |
16 |
17 | {format('on the {DD} {MM} {YYYY} at {HH}:{mm}:{ss}')(
18 | new Date(event.time)
19 | )}
20 |
21 |
22 |
23 | {event.data.commits.length} commit
24 | {event.data.commits.length > 1 ? 's' : ''}{' '}
25 | {event.data.commits.length > 1 ? 'have' : 'has'} been pushed to{' '}
26 |
27 | {event.owner}/{event.name}
28 |
29 | :
30 |
31 |
32 |
33 |
34 |
35 | {event.data.commits.map(commit => (
36 |
37 | ))}
38 |
39 |
40 |
41 | {event.data.more && and more}
42 |
43 | )
44 | }
45 |
46 | export default CommitEvent
47 |
--------------------------------------------------------------------------------
/components/feed/create-repository-event.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 | import format from 'tinydate'
4 |
5 | import Heading from '../heading'
6 | import Link from '../link'
7 | import Text from '../text'
8 |
9 | import { CreateRepositoryEvent as Event } from '../../types/data'
10 |
11 | const CreateRepositoryEvent: React.FC<{ event: Event }> = ({ event }) => {
12 | const base = '/' + event.owner + '/' + event.name
13 | return (
14 |
15 |
16 | {format('on the {DD} {MM} {YYYY} at {HH}:{mm}:{ss}')(
17 | new Date(event.time)
18 | )}
19 |
20 |
21 |
22 | {event.owner} created a new
23 | repository at{' '}
24 |
25 | {event.owner}/{event.name}
26 |
27 |
28 |
29 | )
30 | }
31 |
32 | export default CreateRepositoryEvent
33 |
--------------------------------------------------------------------------------
/components/header.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 |
4 | import { navigate } from '@quercia/quercia'
5 |
6 | import Body from './body'
7 | import Link from './link'
8 | import Avatar from './avatar'
9 | import Logo from './svgs/logo'
10 | import Button from './button'
11 |
12 | import { Base } from '../types/data'
13 |
14 | const Header: React.FC> = ({ account }) => {
15 | return (
16 |
27 |
28 |
29 | navigate('/')} />
30 |
31 | {account ? (
32 |
33 | ) : (
34 |
35 |
36 | Login
37 |
38 |
39 |
40 |
41 |
42 | )}
43 |
44 |
45 | )
46 | }
47 |
48 | export default Header
49 |
--------------------------------------------------------------------------------
/components/heading.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import merge from '../types/xtend'
3 |
4 | import Text, { TextProps } from './text'
5 |
6 | const Heading: React.FC = props => {
7 | return (
8 |
14 | )
15 | }
16 |
17 | export default Heading
18 |
--------------------------------------------------------------------------------
/components/image.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Image as RebassImage, ImageProps } from 'rebass'
3 | import merge from '../types/xtend'
4 | import { SSG } from '@quercia/quercia'
5 |
6 | import Skeleton from './skeleton'
7 |
8 | const Image: React.FC = ({ src, alt, ...props }) => {
9 | const sx = Object.assign(
10 | { borderRadius: 'lg', flexGrow: 0, flexShrink: 0 },
11 | props.sx
12 | )
13 |
14 | if (SSG) {
15 | return
16 | }
17 |
18 | const [loaded, setLoaded] = React.useState(false)
19 | const [hidden, setHidden] = React.useState(false)
20 |
21 | React.useEffect(() => {
22 | // reset values when the src prop changes
23 | setLoaded(false)
24 | setHidden(false)
25 | }, [src])
26 |
27 | return (
28 |
29 | setLoaded(true)}
47 | onError={() => setHidden(true)}
48 | alt={alt}
49 | src={src}
50 | {...(props as any)}
51 | />
52 |
53 | )
54 | }
55 |
56 | export default Image
57 |
--------------------------------------------------------------------------------
/components/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Input as RebassInput, InputProps } from '@rebass/forms'
3 | import { keyframes } from '@emotion/css'
4 | import merge from '../types/xtend'
5 |
6 | const animation = keyframes({
7 | '100': {
8 | background: 'transparent',
9 | color: 'inherit'
10 | }
11 | })
12 |
13 | const Label: React.FC = React.forwardRef((props, ref) => (
14 |
40 | ))
41 |
42 | export default Label
43 |
--------------------------------------------------------------------------------
/components/issue/comment.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 |
4 | import Link from '../link'
5 | import Image from '../image'
6 | import Container from '../base'
7 |
8 | const Comment: React.FC<{ picture: string; name: string }> = ({
9 | children,
10 | picture,
11 | name
12 | }) => (
13 |
14 |
15 |
16 |
17 |
18 | {children}
19 |
20 |
21 | )
22 |
23 | export default Comment
24 |
--------------------------------------------------------------------------------
/components/issue/write.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex, Box } from 'rebass'
3 | import { Textarea } from '@rebass/forms'
4 | import { usePage } from '@quercia/quercia'
5 |
6 | import Comment from './comment'
7 | import Text from '../text'
8 | import Divider from '../divider'
9 |
10 | const Write = React.forwardRef((_, ref) => {
11 | const { account } = usePage()[1]
12 |
13 | return (
14 |
15 |
16 |
17 | {!account ? 'please sign in to comment' : 'about to comment'}:
18 |
19 |
20 |
21 |
44 |
45 | )
46 | })
47 |
48 | export default Write
49 |
--------------------------------------------------------------------------------
/components/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Label as RebassLabel, LabelProps } from '@rebass/forms'
3 | import { variant } from 'styled-system'
4 | import merge from '../types/xtend'
5 |
6 | const Label: React.FC = props => {
7 | const sx = variant({
8 | variants: {
9 | error: {
10 | fontSize: 'sm',
11 | color: 'error'
12 | }
13 | }
14 | })(props)
15 |
16 | return (
17 |
21 | )
22 | }
23 |
24 | export default Label
25 |
--------------------------------------------------------------------------------
/components/link.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { LinkProps as RebassLinkProps } from 'rebass'
3 | import { navigate } from '@quercia/quercia'
4 | import merge from '../types/xtend'
5 |
6 | import Text, { TextProps } from './text'
7 |
8 | const route = (to: string) => (e: Event) => {
9 | e.preventDefault()
10 | e.stopPropagation()
11 | navigate(to)
12 | }
13 |
14 | export type LinkProps = RebassLinkProps &
15 | TextProps & { to?: string; unkown?: boolean }
16 |
17 | // custom implementation of quercia's Link component with Rebass/styled-system
18 | const Link: React.FC = ({ to, ...props }) => {
19 | if (to) {
20 | props.onClick = route(to) as any
21 | props.href = to
22 | }
23 |
24 | return (
25 |
40 | )
41 | }
42 |
43 | export default Link
44 |
--------------------------------------------------------------------------------
/components/list.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box, BoxProps, Flex, FlexProps } from 'rebass'
3 |
4 | export const List: React.FC = props => (
5 |
12 | )
13 |
14 | export const Item: React.FC = ({
15 | selected,
16 | ...props
17 | }) => (
18 |
38 | )
39 |
--------------------------------------------------------------------------------
/components/new/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex, ButtonProps } from 'rebass'
3 |
4 | import Button from '../button'
5 |
6 | const Btn: React.FC = props => (
7 |
8 |
11 |
12 | )
13 |
14 | export default Btn
15 |
--------------------------------------------------------------------------------
/components/new/head.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 |
4 | import Heading from '../heading'
5 | import Dropdown from '../dropdown'
6 | import Relative from '../relative'
7 | import Dropbox from '../dropbox'
8 | import { Item, List } from '../list'
9 |
10 | interface HeadProps {
11 | selected: number
12 | setSelected(i: number): void
13 | types: string[]
14 | }
15 |
16 | const Head: React.FC = ({ selected, setSelected, types }) => {
17 | const [open, setOpen] = React.useState(false)
18 | const select = React.useCallback((i: number) => {
19 | setOpen(false)
20 | setSelected(i)
21 | }, [])
22 |
23 | return (
24 |
25 |
26 | create a new
27 |
28 |
29 | setOpen(true)}>
30 | {types[selected]}
31 |
32 | setOpen(false)}>
33 |
34 | {types.map((type, i) => (
35 | - select(i)}>
36 | {type}
37 |
38 | ))}
39 |
40 |
41 |
42 |
43 | )
44 | }
45 |
46 | export default Head
47 |
--------------------------------------------------------------------------------
/components/new/organization.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 | import { useForm } from 'react-hook-form'
4 | import { navigate } from '@quercia/quercia'
5 |
6 | import Center from '../center'
7 | import Divider from '../divider'
8 | import Input from '../input'
9 | import Label from '../label'
10 | import Button from '../button'
11 |
12 | import { User } from '../../types/data'
13 |
14 | interface Data {
15 | name: string
16 | description: string
17 | }
18 |
19 | const Organization: React.FC<{ user: User }> = ({ user }) => {
20 | const { handleSubmit, register, errors } = useForm()
21 |
22 | const ref = React.useRef()
23 | const onSubmit = React.useCallback((data: Data) => {
24 | // instantiate the POST form data
25 | const body = new FormData()
26 | body.set('kind', 'organization')
27 | body.set('owner', user.name)
28 | body.set('name', data.name)
29 |
30 | navigate(`/new`, 'POST', {
31 | body,
32 | credentials: 'same-origin'
33 | })
34 | }, [])
35 |
36 | return (
37 | <>
38 |
47 |
58 |
59 |
60 |
61 |
69 | {errors.name && (
70 |
73 | )}
74 |
75 | >
76 | )
77 | }
78 |
79 | export default Organization
80 |
--------------------------------------------------------------------------------
/components/profile/org.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box, Flex, FlexProps } from 'rebass'
3 |
4 | import { Left } from '../split'
5 | import Image from '../image'
6 | import Heading from '../heading'
7 | import Text from '../text'
8 | import Link from '../link'
9 |
10 | import { Organization, User } from '../../types/data'
11 |
12 | const Line: React.FC = props => (
13 |
14 | )
15 |
16 | const Profile = ({
17 | profile,
18 | users
19 | }: {
20 | profile: Organization
21 | users: User[]
22 | }) => (
23 |
24 |
34 |
35 |
36 | {profile?.name}
37 |
38 |
39 | 📍
40 | {profile?.location || 'Universe'}
41 |
42 |
43 |
44 | {profile?.description || 'Empty description'}
45 |
46 |
47 |
48 |
49 | {(users || []).map(({ name, picture }, i) => (
50 |
51 |
57 |
58 | ))}
59 |
60 |
61 |
62 | )
63 |
64 | export default Profile
65 |
--------------------------------------------------------------------------------
/components/profile/repos.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex, Box } from 'rebass'
3 |
4 | import { navigate, SSG } from '@quercia/quercia'
5 |
6 | import Link from '../link'
7 | import Heading from '../heading'
8 | import Text from '../text'
9 | import Button from '../button'
10 | import { Right } from '../split'
11 | import VCS from '../svgs/git'
12 |
13 | import { Base } from '../../types/data'
14 | import { Repository as IRepository } from '../../types/repository'
15 |
16 | const Repository: React.FC = props => (
17 |
35 | )
36 |
37 | const Repositories = ({
38 | owner,
39 | repositories,
40 | account
41 | }: Base<{ owner: string; repositories: IRepository[] }>) => {
42 | // rener a placeholder pointing the user to create his/hers first repo
43 | if ((repositories || []).length == 0 && !SSG) {
44 | return (
45 |
46 |
47 | {owner == account?.name ? (
48 | "You don't"
49 | ) : (
50 | <>
51 | {owner}
doesn't
52 | >
53 | )}{' '}
54 | have any repositories yet
55 |
56 |
57 |
58 | {owner == account?.name && (
59 |
60 | )}
61 |
62 | )
63 | }
64 |
65 | if (SSG) {
66 | repositories = Array.from({ length: 3 })
67 | }
68 |
69 | return (
70 |
71 | {(repositories || []).map((repository, i) => (
72 |
73 |
74 | {repository?.name}
75 |
76 |
77 |
78 | {repository?.description}
79 |
80 |
81 |
82 | ))}
83 |
84 | )
85 | }
86 |
87 | export default Repositories
88 |
--------------------------------------------------------------------------------
/components/profile/user.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box, Flex, FlexProps } from 'rebass'
3 |
4 | import { Left } from '../split'
5 | import Image from '../image'
6 | import Base from '../base'
7 | import Heading from '../heading'
8 | import Text from '../text'
9 | import Link from '../link'
10 |
11 | import { User, Organization as Org } from '../../types/data'
12 |
13 | const Line: React.FC = props => (
14 |
15 | )
16 |
17 | const Organization: React.FC = props => (
18 |
19 | )
20 |
21 | const Profile = ({
22 | profile,
23 | organizations
24 | }: {
25 | profile: User
26 | organizations: Org[]
27 | }) => (
28 |
29 |
39 |
40 |
41 | {profile?.name}
42 |
43 |
44 |
45 | {profile?.firstname + ' ' + profile?.lastname}
46 |
47 |
48 |
49 | 📍
50 | {profile?.location || 'Universe'}
51 |
52 |
53 |
54 | {profile?.description || 'Empty description'}
55 |
56 |
57 |
58 | {(organizations || []).map(({ name, picture }, i) => (
59 |
60 |
67 | {name}
68 |
69 | ))}
70 |
71 |
72 | )
73 |
74 | export default Profile
75 |
--------------------------------------------------------------------------------
/components/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box } from 'rebass'
3 |
4 | import { SSG, useRouter } from '@quercia/quercia'
5 |
6 | const Progress: React.FC = () => {
7 | if (SSG) {
8 | return null
9 | }
10 |
11 | const { loading } = useRouter()
12 | const [val, setVal] = React.useState(10)
13 | const [opacity, setOpacity] = React.useState(0)
14 |
15 | React.useEffect(() => {
16 | const bump = () => {
17 | if (!loading) {
18 | clearInterval(handle)
19 | return
20 | }
21 |
22 | setVal(s => (s >= 85 ? s : s + 20))
23 | }
24 |
25 | // if the loading is done reset the value(200ms later)
26 | if (!loading) {
27 | // scroll the page back to the top
28 | window.scrollTo({ top: 0 })
29 |
30 | // reset the progress value
31 | setVal(100)
32 | setTimeout(() => {
33 | setVal(10)
34 | setOpacity(0)
35 | }, 200)
36 | } else {
37 | setVal(10)
38 | setOpacity(1)
39 | }
40 |
41 | const handle = setInterval(bump, 300)
42 | return () => clearInterval(handle)
43 | }, [loading])
44 |
45 | return (
46 |
51 | )
52 | }
53 |
54 | export default Progress
55 |
--------------------------------------------------------------------------------
/components/relative.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box, BoxProps } from 'rebass'
3 | import merge from '../types/xtend'
4 |
5 | const Relative: React.FC = props => (
6 |
7 | )
8 |
9 | export default Relative
10 |
--------------------------------------------------------------------------------
/components/repository/branch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { FlexProps, Text, TextProps } from 'rebass'
3 | import { navigate } from '@quercia/quercia'
4 |
5 | import Relative from '../relative'
6 | import Center from '../center'
7 | import Dropbox from '../dropbox'
8 | import Dropdown from '../dropdown'
9 | import { Item, List } from '../list'
10 |
11 | import { Ref, Repository } from '../../types/repository'
12 |
13 | const HideOnSmall: React.FC = props => (
14 |
15 | )
16 |
17 | const Tag: React.FC = props => (
18 |
30 | )
31 |
32 | const Branch: React.FC<{
33 | current: string
34 | refs: Ref[]
35 | repository: Repository
36 | disabled?: boolean
37 | }> = ({ current, refs, repository, disabled }) => {
38 | const [open, setOpen] = React.useState(false)
39 |
40 | return (
41 |
42 | setOpen(true)}
46 | disabled={disabled}
47 | >
48 | Branch
49 |
50 | : {current}
51 |
52 |
53 | setOpen(false)}>
54 |
55 | {refs.map(ref => (
56 | -
59 | navigate(
60 | `/${repository.owner}/${repository.name}/tree/${ref.sha}`
61 | )
62 | }
63 | selected={ref.name == current}
64 | >
65 | {ref.kind === 'branch' ? 'b' : 't'}
66 | {ref.name}
67 |
68 | ))}
69 |
70 |
71 |
72 | )
73 | }
74 |
75 | export default Branch
76 |
--------------------------------------------------------------------------------
/components/repository/empty.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex, FlexProps } from 'rebass'
3 |
4 | import Container from '../base'
5 | import Center from '../center'
6 | import Divider from '../divider'
7 | import Heading from '../heading'
8 | import Text from '../text'
9 | import Code from '../code'
10 | import NoData from '../svgs/no-data'
11 |
12 | import { Base } from '../../types/repository'
13 |
14 | const Spaced: React.FC = props => (
15 |
16 | )
17 |
18 | const Empty: React.FC> = ({ repository, owns }) => {
19 | const url = `http://${window.location.host}/${repository.owner}/${repository.name}`
20 |
21 | return owns ? (
22 |
23 |
24 | Quickstart guide
25 |
26 |
27 |
28 |
29 | You can clone the repository at this url:
30 |
31 | {url}
32 |
33 |
34 |
35 |
36 | To inizialize and upload a folder:
37 |
38 |
39 | git init
40 |
41 | git add .
42 |
43 | git commit -m "initial commit"
44 |
45 | git remote add origin {url}
46 |
47 | git push -u origin master
48 |
49 |
50 |
51 |
52 |
53 | To upload an existing repository on your system:
54 |
55 |
56 | git remote add origin {url}
57 |
58 | git push -u origin master
59 |
60 |
61 |
62 | ) : (
63 |
64 | This repository is empty
65 |
66 |
67 |
68 | )
69 | }
70 |
71 | export default Empty
72 |
--------------------------------------------------------------------------------
/components/repository/layout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex, Box } from 'rebass'
3 | import { SSG } from '@quercia/quercia'
4 |
5 | import Link from '../link'
6 | import Heading from '../heading'
7 | import { Tab, Tabs } from '../tabs'
8 |
9 | import { Base } from '../../types/repository'
10 |
11 | export type Page =
12 | | 'Overview'
13 | | 'Tree'
14 | | 'Commits'
15 | | 'Issues'
16 | | 'Pulls'
17 | | 'Settings'
18 | const _tabs: [Page, string][] = [
19 | ['Overview', ''],
20 | ['Tree', '/tree/master'],
21 | ['Commits', '/commits/master'],
22 | ['Issues', '/issues'],
23 | ['Pulls', '/pulls'],
24 | ['Settings', '/settings']
25 | ]
26 |
27 | const Layout: React.FC> = ({
28 | page,
29 | children,
30 | repository,
31 | owns
32 | }) => {
33 | let tabs = owns ? _tabs : _tabs.concat().splice(0, _tabs.length - 1)
34 |
35 | const baseURL = SSG ? '' : `/${repository.owner}/${repository.name}`
36 |
37 | return (
38 |
45 |
49 |
50 | {repository?.owner}/
51 | {repository?.name}
52 |
53 |
54 |
55 | {tabs.map(([tab, url], i) => (
56 |
61 | {tab}
62 |
63 | ))}
64 |
65 |
66 | {children}
67 |
68 | )
69 | }
70 | export default Layout
71 |
--------------------------------------------------------------------------------
/components/repository/path.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box } from 'rebass'
3 | import { SSG } from '@quercia/quercia'
4 |
5 | import Link from '../link'
6 |
7 | import {
8 | Blob,
9 | Entry,
10 | EntryKind,
11 | Repository,
12 | Tree
13 | } from '../../types/repository'
14 | import Skeleton from '../skeleton'
15 |
16 | export const key = (entry: Entry): string => {
17 | return entry.kind === EntryKind.BLOB
18 | ? (entry as Blob).name
19 | : (entry as Tree).path
20 | }
21 |
22 | const base = (repo: Repository, tree: Tree, entry: Entry): string =>
23 | `/${repo.owner}/${repo.name}/${
24 | entry.kind === EntryKind.BLOB ? 'blob' : 'tree'
25 | }/${tree.branch.name}`
26 |
27 | export const url = (repo: Repository, tree: Tree, entry: Entry): string =>
28 | `${base(repo, tree, entry)}${
29 | tree.path.startsWith('/') ? tree.path : '/'
30 | }${key(entry)}`
31 |
32 | export const basename = (path: string): string => {
33 | const splits = path.split('/')
34 | return splits[splits.length - 1]
35 | }
36 |
37 | const Path: React.FC<{
38 | entry: Entry
39 | repository: Repository
40 | }> = ({ entry, repository }) => {
41 | if (SSG) {
42 | return
43 | }
44 |
45 | const k = key(entry)
46 | let parts = (k.endsWith('/') ? k.substr(0, k.length - 1) : k).split('/')
47 |
48 | if (k == '.') {
49 | parts = []
50 | }
51 |
52 | const pathTo = (k: string): string => {
53 | return parts.slice(0, parts.indexOf(k) + 1).join('/')
54 | }
55 |
56 | const _base = base(repository, entry as any, { kind: 0 } as any)
57 |
58 | return (
59 |
60 | {repository.name}
61 | {parts.map((path, i) => (
62 |
63 | /
64 | {i !== parts.length - 1 ? (
65 | {path}
66 | ) : (
67 | path
68 | )}
69 |
70 | ))}
71 |
72 | )
73 | }
74 |
75 | export default Path
76 |
--------------------------------------------------------------------------------
/components/repository/tree.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box, BoxProps } from 'rebass'
3 | import * as pretty from 'pretty-bytes'
4 | import { SSG } from '@quercia/quercia'
5 |
6 | import Link from '../link'
7 | import Container from '../base'
8 | import Skeleton from '../skeleton'
9 | import File from '../svgs/file'
10 | import Folder from '../svgs/folder'
11 |
12 | import { basename, key, url } from './path'
13 | import { Except } from 'type-fest'
14 | import { EntryKind, Base, Tree as ITree } from '../../types/repository'
15 |
16 | const Cell: React.FC = props => (
17 |
37 | )
38 |
39 | const rnd = (min: number, max: number) =>
40 | Math.floor(Math.random() * (max - min)) + min
41 |
42 | const Tree: React.FC, 'owns'>> = ({
43 | tree,
44 | repository
45 | }) => {
46 | if (SSG) {
47 | tree = {
48 | children: Array.from({ length: 12 }).map(() => ({ kind: 0 }))
49 | } as any
50 | }
51 |
52 | return (
53 |
63 |
64 |
68 | |
69 | Name |
70 | Size |
71 |
72 |
73 |
74 | {(tree.children || [])
75 | .sort((a, b) => (key(a) > key(b) ? -1 : 1)) // sort alphetically
76 | .sort((a, b) => (a.kind > b.kind ? 1 : -1)) // sort by kind (folder, file)
77 | .map((entry, i) => (
78 |
79 |
80 | {SSG ? (
81 |
82 | ) : entry.kind === EntryKind.BLOB ? (
83 |
84 | ) : (
85 |
86 | )}
87 | |
88 |
89 |
100 | {SSG ? '' : basename(key(entry))}
101 |
102 | |
103 |
104 | {SSG ? (
105 |
106 | ) : (
107 | entry.kind !== EntryKind.TREE && pretty(entry.size)
108 | )}
109 | |
110 |
111 | ))}
112 |
113 |
114 | )
115 | }
116 |
117 | export default Tree
118 |
--------------------------------------------------------------------------------
/components/settings/field.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { NestDataObject, FieldError } from 'react-hook-form'
3 | import { Box } from 'rebass'
4 | import { InputProps } from '@rebass/forms'
5 |
6 | import Heading from '../heading'
7 | import Input from '../input'
8 | import Label from '../label'
9 |
10 | const Field: React.FC
13 | }> = React.forwardRef(
14 | ({ errors, placeholder, name, description, ...props }, ref) => (
15 |
16 |
17 | {placeholder}
18 |
19 |
20 |
26 |
27 |
28 | {errors[name] && (
29 |
32 | )}
33 |
34 | )
35 | )
36 |
37 | export default Field
38 |
--------------------------------------------------------------------------------
/components/settings/left.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { SSG } from '@quercia/quercia'
3 |
4 | import { Left as Split } from '../split'
5 | import { Tab } from '../tabs'
6 |
7 | type Page = 'General' | 'Privacy' | 'Permissions' | 'Hooks'
8 |
9 | interface LeftProps {
10 | current: Page
11 | pages: Page[]
12 | base: string
13 | }
14 |
15 | const Left: React.FC = ({ current, pages, base }) => (
16 |
23 | {pages.map((page, i) => (
24 |
35 | {page}
36 |
37 | ))}
38 |
39 | )
40 |
41 | export default Left
42 |
--------------------------------------------------------------------------------
/components/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Box, BoxProps } from 'rebass'
3 |
4 | export const Skeleton: React.FC = ({ height, width, ...props }) => (
5 |
20 | )
21 |
22 | export default Skeleton
23 |
--------------------------------------------------------------------------------
/components/split.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex, FlexProps } from 'rebass'
3 | import merge from '../types/xtend'
4 |
5 | export const Parent: React.FC = props => (
6 |
17 | )
18 |
19 | export const Left: React.FC = props => (
20 |
30 | )
31 |
32 | export const Right: React.FC = props => (
33 |
44 | )
45 |
--------------------------------------------------------------------------------
/components/svgs/add.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const Add: React.FC> = (
4 | props: React.SVGProps
5 | ) => (
6 |
9 | )
10 |
11 | export default Add
12 |
--------------------------------------------------------------------------------
/components/svgs/arrow.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const Logo: React.FC> = (
4 | props: React.SVGProps
5 | ) => (
6 |
12 | )
13 |
14 | export default Logo
15 |
--------------------------------------------------------------------------------
/components/svgs/file.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const File: React.FC> = (
4 | props: React.SVGProps
5 | ) => (
6 |
12 | )
13 |
14 | if (process.env.NODE_ENV !== 'production') {
15 | File.displayName = 'File'
16 | }
17 |
18 | export default File
19 |
--------------------------------------------------------------------------------
/components/svgs/folder.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const Folder: React.FC> = (
4 | props: React.SVGProps
5 | ) => (
6 |
12 | )
13 |
14 | if (process.env.NODE_ENV !== 'production') {
15 | Folder.displayName = 'Folder'
16 | }
17 |
18 | export default Folder
19 |
--------------------------------------------------------------------------------
/components/svgs/logo.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const Logo: React.FC> = (
4 | props: React.SVGProps
5 | ) => (
6 |
9 | )
10 |
11 | if (process.env.NODE_ENV !== 'production') {
12 | Logo.displayName = 'Logo'
13 | }
14 |
15 | export default Logo
16 |
--------------------------------------------------------------------------------
/components/svgs/no-data.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const NoData: React.FC> = (
4 | props: React.SVGProps
5 | ) => (
6 |
68 | )
69 |
70 | if (process.env.NODE_ENV !== 'production') {
71 | NoData.displayName = 'NoData'
72 | }
73 |
74 | export default NoData
75 |
--------------------------------------------------------------------------------
/components/svgs/notfound.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const NotFound: React.FC> = (
4 | props: React.SVGProps
5 | ) => (
6 |
43 | )
44 |
45 | if (process.env.NODE_ENV !== 'production') {
46 | NotFound.displayName = 'NotFound'
47 | }
48 |
49 | export default NotFound
50 |
--------------------------------------------------------------------------------
/components/svgs/pencil.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const Pencil: React.FC> = (
4 | props: React.SVGProps
5 | ) => (
6 |
12 | )
13 |
14 | export default Pencil
15 |
--------------------------------------------------------------------------------
/components/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex, FlexProps } from 'rebass'
3 | import merge from '../types/xtend'
4 |
5 | import Button from './button'
6 | import Link, { LinkProps } from './link'
7 |
8 | export const Tabs: React.FC = props => (
9 |
21 | )
22 |
23 | export const Tab: React.FC = ({
24 | selected,
25 | to,
26 | ...props
27 | }) => (
28 |
34 |
47 |
48 | )
49 |
--------------------------------------------------------------------------------
/components/text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Text as RebassText, TextProps as RebassTextProps } from 'rebass'
3 | import { SSG } from '@quercia/quercia'
4 | import merge from '../types/xtend'
5 |
6 | import Skeleton from './skeleton'
7 |
8 | export type TextProps = RebassTextProps & { known?: boolean }
9 |
10 | const Text: React.FC = ({
11 | known,
12 | fontSize,
13 | width,
14 | height,
15 | ...props
16 | }) => {
17 | props.sx = Object.assign(
18 | {
19 | fontSize: fontSize || 'sm'
20 | },
21 | props.sx || {}
22 | )
23 |
24 | props.as = props.as || 'span'
25 |
26 | if (SSG && !known) {
27 | // make the width resemble more the size of the text
28 | let w = props.children?.toString().length || 8
29 | if (typeof props.children === 'object') {
30 | w = 8
31 | }
32 | if (w && w > 1) {
33 | w -= 0.5 * (w / 1.75)
34 | }
35 |
36 | return (
37 |
41 |
42 |
43 | )
44 | }
45 |
46 | return
47 | }
48 |
49 | export default Text
50 |
--------------------------------------------------------------------------------
/data/CascadiaCode.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lucat1/o2/67b04c0325f0af05d4b237f90ad8360f08dba7f6/data/CascadiaCode.woff2
--------------------------------------------------------------------------------
/data/o2.ini:
--------------------------------------------------------------------------------
1 | [o2]
2 | jwt_key = "MY_SUPER_SECRET"
3 | log = "data/latest.log"
4 | hooks_log = "data/latest.hooks.log"
5 |
6 | [database]
7 | dialect = mysql
8 |
9 | # connection URI format:
10 | # user:password@(localhost)/dbname?charset=utf8mb4,utf8&parseTime=True&loc=Local
11 | uri = root:root@tcp(localhost)/o2?charset=utf8mb4,utf8&parseTime=True&loc=Local
12 |
13 | [pictures]
14 | directory = "data/pictures"
15 | secret = "a-16-btes-long-s"
16 |
17 | [repositories]
18 | directory = "data/repos"
19 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # this is an example docker-compose to run an o2 web server
2 | # with a mysql database for storage
3 | version: '2'
4 |
5 | networks:
6 | o2:
7 | external: false
8 |
9 | services:
10 | server:
11 | image: lucat1/o2:latest
12 | restart: always
13 | networks:
14 | - o2
15 | volumes:
16 | - ./o2:/data
17 | ports:
18 | - '3000:3000'
19 | depends_on:
20 | - db
21 |
22 | db:
23 | image: mysql:5.7
24 | restart: always
25 | environment:
26 | - MYSQL_ROOT_PASSWORD=root
27 | - MYSQL_USER=o2
28 | - MYSQL_PASSWORD=o2
29 | - MYSQL_DATABASE=o2
30 | networks:
31 | - o2
32 | volumes:
33 | - ./mysql:/var/lib/mysql
34 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/lucat1/o2
2 |
3 | go 1.13
4 |
5 | require (
6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible
7 | github.com/go-sql-driver/mysql v1.5.0
8 | github.com/jcmturner/gokrb5/v8 v8.3.0 // indirect
9 | github.com/jmoiron/sqlx v1.2.0
10 | github.com/kataras/muxie v1.0.9
11 | github.com/lib/pq v1.6.0
12 | github.com/lucat1/quercia v0.4.2
13 | github.com/m1ome/randstr v0.0.0-20170328115817-50e7f2dc0288
14 | github.com/markbates/pkger v0.16.0
15 | github.com/mattn/go-sqlite3 v2.0.3+incompatible
16 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
17 | github.com/omohayui/crawlerdetector v0.0.0-20200410120956-3d3c194a9c94
18 | github.com/patrickmn/go-cache v2.1.0+incompatible
19 | github.com/rs/zerolog v1.19.0
20 | github.com/satori/go.uuid v1.2.0
21 | github.com/simukti/sqldb-logger v0.0.0-20200602044015-843152fd150e
22 | github.com/smartystreets/goconvey v1.6.4 // indirect
23 | golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9
24 | golang.org/x/net v0.0.0-20200602114024-627f9648deb9
25 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
26 | gopkg.in/ini.v1 v1.57.0
27 | )
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "o2.auth",
3 | "version": "0.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "private": true,
7 | "scripts": {
8 | "build": "rm -rf __quercia && quercia build -m=production",
9 | "dev": "rm -rf __quercia && quercia watch"
10 | },
11 | "dependencies": {
12 | "@emotion/css": "^11.0.0-next.12",
13 | "@emotion/react": "^11.0.0-next.12",
14 | "@emotion/server": "^11.0.0-next.12",
15 | "@rebass/forms": "^4.0.6",
16 | "parse-diff": "^0.7.0",
17 | "pretty-bytes": "^5.3.0",
18 | "prismjs": "^1.21.0",
19 | "promisify-file-reader": "^4.0.0",
20 | "react": "^16.13.1",
21 | "react-dom": "^16.13.1",
22 | "react-hook-form": "^5.3.1",
23 | "rebass": "^4.0.7",
24 | "tinydate": "^1.2.0",
25 | "use-onclickoutside": "^0.3.1",
26 | "use-prefers-theme": "^0.1.2"
27 | },
28 | "devDependencies": {
29 | "@quercia/cli": "0.5.3",
30 | "@styled-system/theme-get": "^5.1.2",
31 | "@types/node": "^13.11.1",
32 | "@types/prismjs": "^1.16.0",
33 | "@types/react": "^16.9.32",
34 | "@types/react-dom": "^16.9.6",
35 | "@types/rebass": "^4.0.6",
36 | "@types/rebass__forms": "^4.0.2",
37 | "bundlewatch": "^0.2.6",
38 | "file-loader": "^6.0.0",
39 | "preact": "^10.4.0",
40 | "type-fest": "^0.14.0",
41 | "typescript": "^3.8.3"
42 | },
43 | "resolutions": {
44 | "@emotion/react": "^11.0.0-next.12",
45 | "@emotion/styled": "^11.0.0-next.12"
46 | },
47 | "prettier": {
48 | "printWidth": 80,
49 | "semi": false,
50 | "singleQuote": true,
51 | "jsxSingleQuote": true,
52 | "arrowParens": "avoid",
53 | "trailingComma": "none",
54 | "proseWrap": "always",
55 | "quoteProps": "consistent"
56 | },
57 | "bundlewatch": {
58 | "files": [
59 | {
60 | "path": "__quercia/client/*.js",
61 | "maxSize": "20kB"
62 | },
63 | {
64 | "path": "__quercia/client/pages/**/*.js",
65 | "maxSize": "9kB"
66 | }
67 | ]
68 | },
69 | "volta": {
70 | "node": "12.16.2",
71 | "yarn": "1.22.4"
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Head, SSG } from '@quercia/quercia'
3 |
4 | import Center from '../components/center'
5 | import Heading from '../components/heading'
6 | import NotFound from '../components/svgs/notfound'
7 |
8 | import { Base } from '../types/data'
9 |
10 | export default ({ path }: Base<{ path: string }>) => (
11 |
12 |
13 | not found {!SSG && path} - o2
14 |
15 |
16 | Page not found:
17 |
18 |
19 | {path}
20 |
21 |
22 |
23 | )
24 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import usePrefersTheme, { Preference } from 'use-prefers-theme'
3 | import { Head, SSG } from '@quercia/quercia'
4 | import { AppProps } from '@quercia/runtime'
5 |
6 | import { cache } from '@emotion/css'
7 | import { CacheProvider, ThemeProvider, Global, css } from '@emotion/react'
8 |
9 | import Body from '../components/body'
10 | import Header from '../components/header'
11 | import Progress from '../components/progress'
12 |
13 | import { base } from '../types/theme'
14 | import font from '../data/CascadiaCode.woff2'
15 |
16 | const glob = css`
17 | :root {
18 | --ff: 'Operator Mono', 'Cascadia Code', monospace;
19 | --primary: #8325c1;
20 | --primary-rgb: 199, 146, 234;
21 | --error: #fd9726;
22 |
23 | --red: #e13023;
24 | --green: #008845;
25 |
26 | --bg-3: #d9d9d9;
27 | --bg-4: #f2f2f2;
28 | --bg-5: #ffffff;
29 | --bg-6: #f9f9f9;
30 | --fg-5: #000000;
31 |
32 | @media (prefers-color-scheme: dark) {
33 | --primary: #c792ea;
34 | --bg-3: #303030;
35 | --bg-4: #2b2b2b;
36 | --bg-5: #191919;
37 | --bg-6: #161616;
38 | --fg-5: #ffffff;
39 | }
40 | }
41 |
42 | * {
43 | text-rendering: optimizeLegibility;
44 | -webkit-font-smoothing: antialiased;
45 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
46 | font-feature-settings: 'calt' 1;
47 | font-variant-ligatures: contextual;
48 | }
49 |
50 | html {
51 | background: var(--bg-5);
52 | color: var(--fg-5);
53 | transition: color 0.3s ease-in-out, background 0.3s ease-in-out;
54 | }
55 |
56 | @font-face {
57 | font-family: 'Cascadia Code';
58 | font-style: normal;
59 | font-weight: 500;
60 | font-display: swap;
61 | src: url(${font});
62 | }
63 |
64 | body {
65 | margin: 0;
66 | font-size: calc(1rem + 0.33vw);
67 | font-family: var(--ff);
68 | }
69 | `
70 |
71 | const App: React.FC = ({ Component, pageProps }) => {
72 | if (process.env.NODE_ENV !== 'production' && !SSG) {
73 | console.group('Page infos:')
74 | console.info('props:', pageProps)
75 | console.groupEnd()
76 | }
77 |
78 | const dark = {}
79 | const themes: { [Key in Preference]: any } = {
80 | dark: dark,
81 | light: {},
82 | none: dark
83 | }
84 |
85 | const preference = usePrefersTheme()
86 |
87 | return (
88 |
89 |
90 |
91 |
92 | {preference != 'none' && (
93 |
97 | )}
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | )
108 | }
109 |
110 | export default App
111 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {
3 | QuerciaHead,
4 | QuerciaScripts,
5 | QuerciaMount,
6 | DocumentProps
7 | } from '@quercia/runtime'
8 |
9 | import { extractCritical } from '@emotion/server'
10 | import { EmotionCritical } from '@emotion/server/create-instance'
11 |
12 | export default ({ ids, css }: DocumentProps & EmotionCritical) => (
13 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | )
27 |
28 | export const getInitialProps = ({ renderPage }: DocumentProps) => {
29 | return extractCritical(renderPage())
30 | }
31 |
--------------------------------------------------------------------------------
/pages/feed.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex, Box } from 'rebass'
3 | import { Head, SSG } from '@quercia/quercia'
4 |
5 | import Heading from '../components/heading'
6 | import CommitEvent from '../components/feed/commit-event'
7 | import CreateRepositoryEvent from '../components/feed/create-repository-event'
8 | import NoEvents from '../components/svgs/no-events'
9 |
10 | import {
11 | Event,
12 | CommitEvent as CEvent,
13 | CreateRepositoryEvent as CREvent
14 | } from '../types/data'
15 | import Center from '../components/center'
16 |
17 | interface FeedProps {
18 | events: Event[]
19 | }
20 |
21 | export default ({ events }: FeedProps) => {
22 | return (
23 |
24 |
25 | index - o2
26 |
30 |
31 |
32 | {(events || []).length > 0 ? (
33 | <>
34 |
35 | Latest events:
36 |
37 |
38 | {(events || []).map((event, i) => {
39 | switch (event.type) {
40 | case 'commit':
41 | return
42 |
43 | case 'create-repository':
44 | return (
45 |
46 | )
47 |
48 | default:
49 | return null
50 | }
51 | })}
52 | >
53 | ) : !SSG ? (
54 |
55 |
56 | Noting has happened yet
57 |
58 |
59 | Go make something awesome!
60 |
61 |
62 |
63 | ) : null}
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/pages/login.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { useForm } from 'react-hook-form'
3 | import { Box } from 'rebass'
4 | import { Head, navigate } from '@quercia/quercia'
5 |
6 | import Center from '../components/center'
7 | import Button from '../components/button'
8 | import Input from '../components/input'
9 | import Label from '../components/label'
10 | import Heading from '../components/heading'
11 |
12 | interface Data {
13 | email: string
14 | password: string
15 | }
16 |
17 | interface LoginProps {
18 | error: string
19 | }
20 |
21 | export default ({ error }: LoginProps) => {
22 | const [isLoading, setLoading] = React.useState(
23 | error && typeof error !== 'string'
24 | )
25 | const { handleSubmit, register, errors } = useForm()
26 |
27 | const onSubmit = (data: Data) => {
28 | setLoading(true)
29 |
30 | // instantiate the POST form data
31 | const body = new FormData()
32 | body.set('email', data.email)
33 | body.set('password', data.password)
34 |
35 | navigate(`/login${window.location.search}`, 'POST', {
36 | body,
37 | credentials: 'same-origin'
38 | })
39 | }
40 |
41 | return (
42 |
47 |
48 | login - o2
49 |
50 | {error && {error}}
51 |
52 |
53 |
65 | {errors.email && (
66 |
69 | )}
70 |
71 |
72 |
73 |
87 | {errors.password && (
88 |
91 | )}
92 |
93 |
94 |
97 |
98 | )
99 | }
100 |
--------------------------------------------------------------------------------
/pages/new.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Head } from '@quercia/quercia'
3 |
4 | import Container from '../components/base'
5 | import Center from '../components/center'
6 | import Heading from '../components/heading'
7 | import Divider from '../components/divider'
8 |
9 | import Header from '../components/new/head'
10 | import Repository from '../components/new/repository'
11 | import Organization from '../components/new/organization'
12 |
13 | import { Base, User, Organization as Org } from '../types/data'
14 |
15 | interface AddProps {
16 | error?: string
17 | user: User
18 | organizations: Org[]
19 | }
20 |
21 | const types = ['Repository', 'Organization']
22 |
23 | export default ({ user, organizations, error }: Base) => {
24 | const [selected, setSelected] = React.useState(0)
25 | const Element = [Repository, Organization][selected] as any
26 |
27 | return (
28 |
29 |
30 | new - o2
31 |
35 |
36 |
37 |
38 |
39 | {error && {error}}
40 |
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/pages/organization.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Head, SSG } from '@quercia/quercia'
3 |
4 | import { Parent } from '../components/split'
5 | import Repos from '../components/profile/repos'
6 | import Org from '../components/profile/org'
7 |
8 | import { Base, Organization, User } from '../types/data'
9 | import { Repository } from '../types/repository'
10 |
11 | export default ({
12 | profile,
13 | account,
14 | users,
15 | repositories
16 | }: Base<{
17 | profile: Organization
18 | repositories: Repository[]
19 | users: User[]
20 | }>) => (
21 |
22 |
23 | {SSG ? 'user' : profile.name} - o2
24 |
28 |
29 |
30 |
35 |
36 | )
37 |
--------------------------------------------------------------------------------
/pages/repository/blob.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 | import { Head, SSG } from '@quercia/quercia'
4 | import * as pretty from 'pretty-bytes'
5 | import { highlight, languages } from 'prismjs/components/prism-core'
6 |
7 | import Container from '../../components/base'
8 | import Divider from '../../components/divider'
9 | import Text from '../../components/text'
10 | import Link from '../../components/link'
11 | import Layout from '../../components/repository/layout'
12 | import Path, { basename } from '../../components/repository/path'
13 |
14 | import Gutter from '../../components/code/gutter'
15 | import Pre from '../../components/code/pre'
16 | import load, { lang } from '../../components/code/load'
17 |
18 | import { Base, BlobProps } from '../../types/repository'
19 |
20 | // TODO: proper prerender
21 | export default ({ repository, owns, blob, data, ext }: Base) => {
22 | const [loaded, setLoaded] = React.useState(false)
23 |
24 | React.useEffect(() => {
25 | if (ext != '') {
26 | load(ext).then(() => setLoaded(true))
27 | }
28 | }, [ext])
29 |
30 | const language = lang(ext)
31 |
32 | return (
33 | <>
34 |
35 |
36 | {typeof repository === 'object'
37 | ? `${repository.name}/${blob.name}`
38 | : 'blob'}{' '}
39 | - o2
40 |
41 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | {!SSG && basename(blob.name)}
52 |
53 |
54 | raw
55 |
56 |
57 |
58 | {!SSG && pretty(blob?.size)}
59 |
60 |
61 |
62 |
63 |
64 | {data?.split('\n').map((_, i) => `${i + 1}\n`)}
65 | {loaded && languages[language] ? (
66 |
71 | ) : (
72 | {data}
73 | )}
74 |
75 |
76 |
77 | >
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/pages/repository/commit.tsx:
--------------------------------------------------------------------------------
1 | import * as diff from 'parse-diff'
2 | import * as React from 'react'
3 | import { Head, SSG } from '@quercia/quercia'
4 |
5 | import Layout from '../../components/repository/layout'
6 | import Text from '../../components/text'
7 | import Commit from '../../components/commit/commit'
8 | import Diff from '../../components/commit/diff'
9 |
10 | import { DetailedCommit } from '../../types/data'
11 | import { Base } from '../../types/repository'
12 |
13 | // TODO: proper prerender
14 | export default ({
15 | repository,
16 | commit,
17 | owns
18 | }: Base<{ commit: DetailedCommit }>) => {
19 | const files = diff(commit?.diff)
20 | const additions = files.reduce((p, f) => p + f.additions, 0)
21 | const deletions = files.reduce((p, f) => p + f.deletions, 0)
22 |
23 | return (
24 |
25 |
26 |
27 | {typeof commit === 'object' && typeof repository === 'object'
28 | ? `${commit.subject} - ${repository.owner}/${repository.name}`
29 | : ''}
30 | - o2
31 |
32 |
36 |
37 |
38 |
42 |
43 | Showing {files.length} changed file{files.length > 1 ? 's' : ''} with{' '}
44 | {additions} additions and{' '}
45 | {deletions} deletions
46 |
47 | {files.map((file, i) => (
48 |
49 | ))}
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/pages/repository/commits.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Head, SSG } from '@quercia/quercia'
3 |
4 | import Center from '../../components/center'
5 | import Button from '../../components/button'
6 | import Link from '../../components/link'
7 | import Commit from '../../components/commit/commit'
8 | import Layout from '../../components/repository/layout'
9 |
10 | import { Commit as ICommit } from '../../types/data'
11 | import { Base } from '../../types/repository'
12 |
13 | export interface CommitsProps {
14 | branch: string
15 | index: number
16 | prev: boolean
17 | next: boolean
18 | commits: ICommit[]
19 | }
20 |
21 | export default ({
22 | repository,
23 | owns,
24 | commits,
25 | prev,
26 | next,
27 | index,
28 | branch
29 | }: Base) => {
30 | if (SSG) {
31 | commits = Array.from({ length: 20 })
32 | }
33 |
34 | const base = `/${repository?.owner}/${repository?.name}`
35 | const url = (i: number) => `${base}/commits/${branch}/${i}`
36 |
37 | return (
38 |
39 |
40 |
41 | {typeof repository === 'object'
42 | ? `${repository.owner}/${repository.name}`
43 | : ''}
44 | {' commits'}- o2
45 |
46 |
50 |
51 |
52 | {(commits || []).map((commit, i) => (
53 |
54 | ))}
55 |
56 |
57 |
63 |
64 |
65 |
71 |
72 |
73 |
74 |
75 | )
76 | }
77 |
--------------------------------------------------------------------------------
/pages/repository/issues.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Head, usePage } from '@quercia/quercia'
3 | import { Flex } from 'rebass'
4 |
5 | import Layout from '../../components/repository/layout'
6 | import Input from '../../components/input'
7 | import Button from '../../components/button'
8 | import Link from '../../components/link'
9 | import Text from '../../components/text'
10 | import Heading from '../../components/heading'
11 | import Container from '../../components/base'
12 |
13 | import { Base, Issue } from '../../types/repository'
14 | import elapsed from '../../types/time'
15 |
16 | export interface IssuesProps {
17 | issues: Issue[]
18 | }
19 |
20 | export default ({ repository, owns, issues }: Base) => {
21 | const { account } = usePage()[1]
22 | const base = `/${repository?.owner}/${repository?.name}`
23 | return (
24 |
25 |
26 |
27 | {typeof repository === 'object'
28 | ? `${repository.owner}/${repository.name}`
29 | : ''}
30 | {' issues'}- o2
31 |
32 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | {(issues || []).map(issue => (
46 |
47 |
48 | {issue.title}
49 |
50 |
51 | opened by {issue.name}{' '}
52 | {elapsed(issue?.opened)}
53 |
54 |
55 | ))}
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/pages/repository/new.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 | import { Textarea } from '@rebass/forms'
4 | import { useForm } from 'react-hook-form'
5 | import { Head, navigate } from '@quercia/quercia'
6 |
7 | import Layout from '../../components/repository/layout'
8 | import Field from '../../components/settings/field'
9 | import Button from '../../components/button'
10 | import Input from '../../components/input'
11 |
12 | import { Base, Issue } from '../../types/repository'
13 |
14 | export interface IssuesProps {
15 | issues: Issue[]
16 | }
17 |
18 | interface Data {
19 | title: string
20 | body: string
21 | }
22 |
23 | export default ({ repository, owns }: Base) => {
24 | const [isLoading, setLoading] = React.useState(false)
25 | const { register, errors, handleSubmit } = useForm()
26 |
27 | const onSubmit = (data: Data) => {
28 | setLoading(true)
29 |
30 | // instantiate the POST form data
31 | const body = new FormData()
32 | body.set('title', data.title)
33 | body.set('body', data.body)
34 |
35 | navigate(window.location.pathname, 'POST', {
36 | body,
37 | credentials: 'same-origin'
38 | })
39 | }
40 |
41 | return (
42 |
43 |
44 |
45 | {typeof repository === 'object'
46 | ? `${repository.owner}/${repository.name}`
47 | : ''}
48 | {' new issue'}- o2
49 |
50 |
54 |
55 |
56 |
64 |
65 |
77 |
78 |
79 |
80 |
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/pages/repository/repository.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Flex } from 'rebass'
3 | import { Head, SSG } from '@quercia/quercia'
4 | import mdown from '../../types/mdown'
5 |
6 | import Container from '../../components/base'
7 | import Text from '../../components/text'
8 |
9 | import Layout from '../../components/repository/layout'
10 | import Branch from '../../components/repository/branch'
11 | import Tree from '../../components/repository/tree'
12 |
13 | import Empty from '../../components/repository/empty'
14 | import { Base, RepositoryProps } from '../../types/repository'
15 |
16 | export default ({
17 | repository,
18 | owns,
19 | tree,
20 | readme,
21 | refs
22 | }: Base) => {
23 | if (!refs) {
24 | refs = []
25 | }
26 |
27 | return (
28 |
29 |
30 |
31 | {typeof repository === 'object'
32 | ? `${repository.owner}/${repository.name}`
33 | : 'repository'}{' '}
34 | - o2
35 |
36 |
37 |
38 |
45 |
46 | {repository?.description}
47 |
48 |
49 |
55 |
56 | {!tree && !SSG && }
57 | {(tree || SSG) && }
58 | {readme && (
59 |
63 | )}
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/pages/repository/settings.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { SSG, Head } from '@quercia/quercia'
3 |
4 | import Layout from '../../components/repository/layout'
5 | import Left from '../../components/settings/left'
6 | import { Parent, Right } from '../../components/split'
7 |
8 | import { Repository } from '../../types/repository'
9 |
10 | export interface SettingsProps {
11 | repository: Repository
12 | owns: boolean
13 | }
14 |
15 | export default ({ repository, owns }: SettingsProps) => {
16 | return (
17 | <>
18 |
19 |
20 | {typeof repository === 'object'
21 | ? `${repository.owner}/${repository.name}`
22 | : 'unkown'}{' '}
23 | settings - o2
24 |
25 |
29 |
30 |
31 |
32 |
37 | right hand side
38 |
39 |
40 | >
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/pages/repository/tree.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { Head, SSG } from '@quercia/quercia'
4 |
5 | import Layout from '../../components/repository/layout'
6 | import Path from '../../components/repository/path'
7 | import Tree from '../../components/repository/tree'
8 |
9 | import { Base, RepositoryProps } from '../../types/repository'
10 |
11 | export default ({ repository, tree, owns }: Base) => {
12 | return (
13 |
14 |
15 |
16 | {typeof repository === 'object'
17 | ? `${repository.name}/${tree.path}`
18 | : 'tree'}{' '}
19 | - o2
20 |
21 |
25 |
26 |
27 |
28 | {(tree || SSG) && }
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/pages/user.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Head, SSG } from '@quercia/quercia'
3 |
4 | import { Parent } from '../components/split'
5 | import Repos from '../components/profile/repos'
6 | import User from '../components/profile/user'
7 |
8 | import { User as IUser, Base, Organization } from '../types/data'
9 | import { Repository } from '../types/repository'
10 |
11 | export default ({
12 | profile,
13 | account,
14 | organizations,
15 | repositories
16 | }: Base<{
17 | repositories: Repository[]
18 | profile: IUser
19 | organizations: Organization[]
20 | }>) => (
21 |
22 |
23 | {SSG ? 'user' : profile.name} - o2
24 |
28 |
29 |
30 |
35 |
36 | )
37 |
--------------------------------------------------------------------------------
/pkg/auth/is.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/dgrijalva/jwt-go"
8 | "github.com/lucat1/o2/pkg/store"
9 | )
10 |
11 | // isAuthenticated checks if the incoming request is properly authenticated
12 | func isAuthenticated(r *http.Request) (*Claims, bool) {
13 | cookie, err := r.Cookie("token")
14 | if err != nil {
15 | return nil, false
16 | }
17 |
18 | // ignore empty tokens and maxAge = 0 as it
19 | // means that the user has logged out
20 | if cookie.Expires.Before(time.Now()) && cookie.Value == "" {
21 | return nil, false
22 | }
23 |
24 | return _isAuthenticated(cookie.Value)
25 | }
26 |
27 | // internal method used to check the token string
28 | func _isAuthenticated(value string) (*Claims, bool) {
29 | claims := &Claims{}
30 |
31 | key := store.GetConfig().Section("o2").Key("jwt_key").String()
32 | token, err := jwt.ParseWithClaims(value, claims, func(token *jwt.Token) (interface{}, error) {
33 | return []byte(key), nil
34 | })
35 |
36 | if err != nil || !token.Valid {
37 | return nil, false
38 | }
39 |
40 | return claims, true
41 | }
42 |
--------------------------------------------------------------------------------
/pkg/auth/login.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/lucat1/o2/pkg/models"
7 | "golang.org/x/crypto/bcrypt"
8 | )
9 |
10 | // Login checks the given email/password and authenticates a user
11 | func Login(user *models.User) (string, error) {
12 | var (
13 | found models.User
14 | err error
15 | )
16 |
17 | if user.Email != "" {
18 | found, err = models.GetUser("email", user.Email)
19 | } else {
20 | // use `name` instead for git cli logins
21 | found, err = models.GetUser("name", user.Name)
22 | }
23 |
24 | if err != nil {
25 | return "", errors.New("Invalid email address")
26 | }
27 |
28 | if bcrypt.CompareHashAndPassword([]byte(found.Password), []byte(user.Password)) != nil {
29 | return "", errors.New("Invalid password")
30 | }
31 |
32 | return Token(found)
33 | }
34 |
--------------------------------------------------------------------------------
/pkg/auth/middleware.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/lucat1/o2/pkg/log"
9 | "github.com/lucat1/o2/pkg/models"
10 | "github.com/lucat1/quercia"
11 | )
12 |
13 | type authType string
14 |
15 | const (
16 | // ClaimsKey is the key used to obtain the user's logged in JWT claims
17 | ClaimsKey authType = "claims"
18 |
19 | // AccountKey is the key to retrieve the user's logged in struct
20 | AccountKey authType = "account"
21 | )
22 |
23 | // With checks if the user is authenticated via JWTs
24 | func With(f http.Handler) http.Handler {
25 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26 | claims, is := isAuthenticated(r)
27 | if !is {
28 | f.ServeHTTP(w, r)
29 | return
30 | }
31 |
32 | // fetch the currently logged in user
33 | user, err := models.GetUser("uuid", claims.UUID)
34 | if err != nil {
35 | log.Debug().
36 | Err(err).
37 | Str("uuid", claims.UUID.String()).
38 | Msg("Could not get logged in user account")
39 |
40 | f.ServeHTTP(w, r)
41 | return
42 | }
43 |
44 | f.ServeHTTP(w, r.WithContext(
45 | context.WithValue(
46 | context.WithValue(r.Context(), AccountKey, &user),
47 | ClaimsKey, claims,
48 | ),
49 | ))
50 | })
51 | }
52 |
53 | // Must forbids access to certain pages without authentication, uses the provided
54 | // handler to display the erroring page
55 | func Must(f http.HandlerFunc) http.HandlerFunc {
56 | return func(w http.ResponseWriter, r *http.Request) {
57 | if !IsAuthenticated(r) {
58 | // don't redirect git requests but instead ask for a BasicAuth
59 | if strings.Contains(r.Header.Get("User-Agent"), "git") {
60 | authHead := r.Header.Get("Authorization")
61 | if len(authHead) == 0 {
62 | w.Header().Add("WWW-Authenticate", "Basic realm=\".\"")
63 | w.WriteHeader(http.StatusUnauthorized)
64 | return
65 | }
66 |
67 | name, password, ok := r.BasicAuth()
68 | if !ok {
69 | w.WriteHeader(http.StatusBadRequest)
70 | return
71 | }
72 |
73 | token, err := Login(&models.User{
74 | Name: name,
75 | Password: password,
76 | })
77 | if err != nil {
78 | w.WriteHeader(http.StatusUnauthorized)
79 | return
80 | }
81 |
82 | SetCookie(w, r, token)
83 | f.ServeHTTP(w, r)
84 | return
85 | }
86 |
87 | quercia.Redirect(w, r, "/login?to="+r.URL.Path, "login", quercia.Props{})
88 | return
89 | }
90 |
91 | f.ServeHTTP(w, r)
92 | }
93 | }
94 |
95 | // IsAuthenticated checks if the request has a reference to the user's claims
96 | // inside the context.
97 | func IsAuthenticated(r *http.Request) bool {
98 | return r.Context().Value(ClaimsKey) != nil
99 | }
100 |
--------------------------------------------------------------------------------
/pkg/auth/register.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/lucat1/o2/pkg/log"
7 | "github.com/lucat1/o2/pkg/models"
8 | "golang.org/x/crypto/bcrypt"
9 | )
10 |
11 | // Register creates a new database instance of the given user
12 | func Register(user *models.User, password string) (string, error) {
13 | if _, err := models.GetUser("name", user.Name); err == nil {
14 | return "", errors.New("The username is taken")
15 | }
16 |
17 | if _, err := models.GetUser("email", user.Email); err == nil {
18 | return "", errors.New("The email is already in use for another account")
19 | }
20 |
21 | hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
22 | if err != nil {
23 | return "", errors.New("Internal error. Please retry with a different password")
24 | }
25 |
26 | user.Password = string(hashed)
27 |
28 | if err := user.Insert(); err != nil {
29 | log.Error().Err(err).Msg("Error while registering new user")
30 | return "", errors.New("Internal error. Please try again later")
31 | }
32 |
33 | return Token(*user)
34 | }
35 |
--------------------------------------------------------------------------------
/pkg/auth/token.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net/http"
7 | "time"
8 |
9 | "github.com/dgrijalva/jwt-go"
10 | "github.com/lucat1/o2/pkg/models"
11 | "github.com/lucat1/o2/pkg/store"
12 | uuid "github.com/satori/go.uuid"
13 | )
14 |
15 | // Claims is the struct serialized into the JWT
16 | type Claims struct {
17 | UUID uuid.UUID `json:"uuid"`
18 |
19 | jwt.StandardClaims
20 | }
21 |
22 | // the lifespan of the token
23 | const lifespan = 8 * time.Hour
24 |
25 | // Token generates a login JWT for the requested user
26 | func Token(user models.User) (string, error) {
27 | claims := &Claims{
28 | UUID: user.UUID,
29 | StandardClaims: jwt.StandardClaims{
30 | ExpiresAt: time.Now().Add(lifespan).Unix(),
31 | },
32 | }
33 |
34 | key := store.GetConfig().Section("o2").Key("jwt_key").String()
35 | _token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
36 | token, err := _token.SignedString([]byte(key))
37 | if err != nil {
38 | return "", errors.New("JWT: " + err.Error())
39 | }
40 |
41 | return token, nil
42 | }
43 |
44 | // SetCookie sets the cookie on the connection
45 | func SetCookie(w http.ResponseWriter, r *http.Request, token string) {
46 | cookie := &http.Cookie{
47 | Name: "token",
48 | Value: token,
49 | MaxAge: int(lifespan),
50 | Expires: time.Now().Add(lifespan),
51 | }
52 |
53 | // set the cookie and then add the data to the context
54 | http.SetCookie(w, cookie)
55 |
56 | claims, _ := _isAuthenticated(token)
57 | user, _ := models.GetUser("uuid", claims.UUID)
58 | *r = *r.WithContext(context.WithValue(
59 | context.WithValue(r.Context(), AccountKey, &user),
60 | ClaimsKey, claims),
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/pkg/data/base.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/auth"
7 | "github.com/lucat1/o2/pkg/models"
8 | "github.com/lucat1/quercia"
9 | )
10 |
11 | // Base generates the basic data contained in each respose, such as the login status
12 | var Base Composer = func(r *http.Request) quercia.Props {
13 | if !auth.IsAuthenticated(r) {
14 | return quercia.Props{}
15 | }
16 |
17 | user, ok := r.Context().Value(auth.AccountKey).(*models.User)
18 | // not logged indeed
19 | if !ok {
20 | return quercia.Props{}
21 | }
22 |
23 | return quercia.Props{
24 | "account": quercia.Props{
25 | "email": user.Email,
26 | "name": user.Name,
27 | "picture": user.Picture,
28 | },
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/pkg/data/compose.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/quercia"
7 | )
8 |
9 | // Composer is a function that returns a series of props from the data obtained by ther http request
10 | type Composer = func(r *http.Request) quercia.Props
11 |
12 | // Compose calls all the given function and merges the results into a sigle
13 | // `quercia.Props` map
14 | func Compose(r *http.Request, composers ...Composer) quercia.Props {
15 | var res quercia.Props = quercia.Props{}
16 | for _, composer := range composers {
17 | res = merge(res, composer(r))
18 | }
19 |
20 | return res
21 | }
22 |
23 | // recursively merge two maps
24 | func merge(a quercia.Props, b quercia.Props) quercia.Props {
25 | res := a
26 | for k, v := range b {
27 | _, isMapA := a[k].(quercia.Props)
28 | _, isMapB := v.(quercia.Props)
29 | if isMapA && isMapB {
30 | res[k] = merge(a[k].(quercia.Props), v.(quercia.Props))
31 | continue
32 | }
33 |
34 | res[k] = v
35 | }
36 |
37 | return res
38 | }
39 |
--------------------------------------------------------------------------------
/pkg/data/with.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/quercia"
7 | )
8 |
9 | // WithAny is a composer to assing any value to a string key(key->value)
10 | func WithAny(key string, value interface{}) Composer {
11 | return func(r *http.Request) quercia.Props {
12 | res := quercia.Props{}
13 | res[key] = value
14 | return res
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/pkg/git/blob.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import "strings"
4 |
5 | // Blob returns a blob object just for reading purpuses
6 | func (b Branch) Blob(name string) Blob {
7 | return Blob{
8 | Base: Base{
9 | Kind: BlobKind,
10 | Branch: b,
11 | },
12 | Name: name,
13 | }
14 | }
15 |
16 | func (b *Blob) Read() (string, error) {
17 | path := b.Branch.Name + ":" + strings.Replace(b.Name, " ", "\\ ", -1)
18 | res, err := Command(b.Branch.repo.Path, "show", path)
19 | if err != nil {
20 | return "", err
21 | }
22 |
23 | b.Size = uint64(res.Len())
24 | return res.String(), nil
25 | }
26 |
--------------------------------------------------------------------------------
/pkg/git/branch.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | // Branch is a object representing a git branch,
4 | // tough it doesn't guarantee that it exists
5 | type Branch struct {
6 | repo *Repository
7 | Name string `json:"name"`
8 | }
9 |
10 | // Branch returns the theoretical branch object
11 | func (r *Repository) Branch(branch string) Branch {
12 | return Branch{
13 | repo: r,
14 | Name: branch,
15 | }
16 | }
17 |
18 | // Exists returns the existanche of a git branch
19 | func (b *Branch) Exists() bool {
20 | _, err := Command(b.repo.Path, "rev-parse", "--verify", b.Name)
21 | return err == nil
22 | }
23 |
24 | // ID returns the branch sha
25 | func (b *Branch) ID() (string, error) {
26 | id, err := Command(b.repo.Path, "rev-parse", "--verify", b.Name)
27 | if err != nil {
28 | return "", err
29 | }
30 |
31 | return id.String(), nil
32 | }
33 |
--------------------------------------------------------------------------------
/pkg/git/command.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "bytes"
5 | "os/exec"
6 |
7 | "github.com/lucat1/o2/pkg/log"
8 | )
9 |
10 | // Command executes a git command with the given arguments in the given location
11 | func Command(pwd string, args ...string) (buf *bytes.Buffer, err error) {
12 | cmd := exec.Command("git", args...)
13 | buf = &bytes.Buffer{}
14 | cmd.Stdout = buf
15 |
16 | if pwd != "" {
17 | cmd.Dir = pwd
18 | }
19 |
20 | log.Debug().
21 | Str("pwd", cmd.Dir).
22 | Str("sub", args[0]).
23 | Msg("Running git command")
24 |
25 | err = cmd.Run()
26 | return
27 | }
28 |
--------------------------------------------------------------------------------
/pkg/git/commit.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "strings"
7 |
8 | "github.com/lucat1/o2/pkg/models"
9 | "github.com/m1ome/randstr"
10 | )
11 |
12 | // DetailedCommit contains the commit info and also
13 | type DetailedCommit struct {
14 | Commit
15 |
16 | Diff string `json:"diff"`
17 | }
18 |
19 | // Commit returns a single git commit with great detail
20 | func (r Repository) Commit(sha string) (DetailedCommit, error) {
21 | sep := randstr.GetString(10) + "\n"
22 | buf, err := Command(
23 | r.Path,
24 | "show",
25 | sha,
26 | "--pretty=format:{\"commit\": \"%H\",\"abbrv\": \"%h\",\"tree\": \"%T\",\"abbrv_tree\": \"%t\",\"author\": { \"name\": \"%aN\", \"email\": \"%aE\", \"date\": \"%aD\"},\"commiter\": { \"name\": \"%cN\", \"email\": \"%cE\", \"date\": \"%cD\"}}"+sep+"%s"+sep+"%b"+sep,
27 | )
28 | if err != nil {
29 | return DetailedCommit{}, err
30 | }
31 |
32 | // parse the output
33 | parts := strings.Split(buf.String(), sep)
34 | if len(parts) != 4 {
35 | return DetailedCommit{}, errors.New("Corrupted git show output")
36 | }
37 | data, subject, body, diff := parts[0], parts[1], parts[2], parts[3]
38 |
39 | var out DetailedCommit
40 | if err = json.Unmarshal([]byte(data), &out); err != nil {
41 | return out, err
42 | }
43 | out.Subject = subject
44 | out.Body = body
45 | picture, err := models.GetPicture(out.Author.Name)
46 | if err != nil {
47 | return out, err
48 | }
49 | out.Author.Picture = picture
50 |
51 | // add git diff
52 | out.Diff = diff
53 | return out, nil
54 | }
55 |
--------------------------------------------------------------------------------
/pkg/git/hooks.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | var postReceiveHook = []byte(`#!/bin/sh
4 | $O2_POST_RECEIVE --config $CONFIGPATH "$@" <&0
5 | `)
6 |
--------------------------------------------------------------------------------
/pkg/git/init.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "io/ioutil"
5 | "os"
6 | "path"
7 |
8 | "github.com/lucat1/o2/pkg/log"
9 | )
10 |
11 | // Init initializes a bare git repository at the given path
12 | func Init(uuid string) (*Repository, error) {
13 | dir := GetPath(uuid)
14 | _, err := Command("", "init", "--bare", dir)
15 | if err != nil {
16 | return nil, err
17 | }
18 |
19 | // Minimalize fs size by removing useless files (for now useless)
20 | if err = os.RemoveAll(path.Join(dir, "hooks")); err != nil {
21 | log.Error().Err(err).Msg("Error while removing info folder in new repo")
22 | }
23 | if err = os.RemoveAll(path.Join(dir, "info")); err != nil {
24 | log.Error().Err(err).Msg("Error while removing info folder in new repo")
25 | }
26 | if err = os.Remove(path.Join(dir, "description")); err != nil {
27 | log.Error().Err(err).Msg("Error while removing description file in new repo")
28 | }
29 |
30 | // setup hooks
31 | if err = os.Mkdir(path.Join(dir, "hooks"), 0700); err != nil {
32 | log.Error().Err(err).Msg("Error while creating the hooks folder in the new repo")
33 | }
34 | if err = ioutil.WriteFile(path.Join(dir, "hooks", "post-receive"), postReceiveHook, 0700); err != nil {
35 | log.Error().Err(err).Msg("Error while creating the `post-receive` hook in the new repo")
36 | }
37 |
38 | return Get(uuid)
39 | }
40 |
--------------------------------------------------------------------------------
/pkg/git/path.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "path"
5 |
6 | "github.com/lucat1/o2/pkg/store"
7 | )
8 |
9 | // GetPath returns the path to a git repository
10 | func GetPath(uuid string) string {
11 | repos := store.GetConfig().Section("repositories").Key("directory").String()
12 | if !path.IsAbs(repos) {
13 | repos = path.Join(store.GetCwd(), repos)
14 | }
15 |
16 | return path.Join(repos, uuid)
17 | }
18 |
--------------------------------------------------------------------------------
/pkg/git/refs.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "errors"
5 | "strings"
6 | )
7 |
8 | // RefType is the type of a Ref
9 | type RefType string
10 |
11 | const (
12 | // BranchType is used to idetify branch refs
13 | BranchType RefType = "branch"
14 |
15 | // TagType is used to idetify tag refs
16 | TagType RefType = "tag"
17 | )
18 |
19 | // Ref is a struct which holds the git ref basic data
20 | type Ref struct {
21 | Kind RefType `json:"kind"`
22 | Name string `json:"name"`
23 | SHA string `json:"sha"`
24 | }
25 |
26 | // Refs returns all the brances and tags available in the repo (refs)
27 | func (r *Repository) Refs() ([]Ref, error) {
28 | refs := []Ref{}
29 | res, err := Command(r.Path, "show-ref")
30 | if err != nil {
31 | return refs, err
32 | }
33 |
34 | lines := strings.Split(res.String(), "\n")
35 | for _, line := range lines {
36 | if line == "" {
37 | continue
38 | }
39 |
40 | parts := strings.Split(line, " ")
41 | kind, name := RefType(""), ""
42 |
43 | if parts[1][:6] == "refs/t" {
44 | kind = TagType
45 | name = parts[1][10:]
46 | }
47 |
48 | if parts[1][:6] == "refs/h" {
49 | kind = BranchType
50 | name = parts[1][11:]
51 | }
52 |
53 | if len(kind) == 0 {
54 | return refs, errors.New("Invalid ref type")
55 | }
56 |
57 | refs = append(refs, Ref{
58 | Kind: kind,
59 | Name: name,
60 | SHA: parts[0],
61 | })
62 | }
63 |
64 | return refs, nil
65 | }
66 |
--------------------------------------------------------------------------------
/pkg/git/repository.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "errors"
5 | "os"
6 | )
7 |
8 | // Repository is the struct that holds the data for the repository
9 | type Repository struct {
10 | Path string
11 | }
12 |
13 | // Get returns the repository object for the given bare repo
14 | func Get(uuid string) (*Repository, error) {
15 | path := GetPath(uuid)
16 | if _, err := os.Stat(path); os.IsNotExist(err) {
17 | return nil, errors.New("The git repository at the given path does not exist")
18 | }
19 |
20 | return &Repository{path}, nil
21 | }
22 |
--------------------------------------------------------------------------------
/pkg/git/tree.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "strconv"
5 | "strings"
6 | )
7 |
8 | // EntryKind can be either blob or tree
9 | type EntryKind uint8
10 |
11 | const (
12 | // TreeKind is the tree entry kind
13 | TreeKind EntryKind = 0
14 | // BlobKind is the blob entry kind
15 | BlobKind EntryKind = 1
16 | )
17 |
18 | // Entry is a fake type to group both Trees and Blobs
19 | type Entry interface{}
20 |
21 | // Base is a struct wich holds all shared fields between trees and blobs
22 | type Base struct {
23 | ID string `json:"-"`
24 | Kind EntryKind `json:"kind"`
25 |
26 | Branch Branch `json:"branch"`
27 | Mode string `json:"mode"`
28 | Size uint64 `json:"size"`
29 | }
30 |
31 | // Tree is a group of blobs and possibly other trees
32 | type Tree struct {
33 | Entry `json:"-"`
34 | Base
35 |
36 | Path string `json:"path"`
37 | Children []Entry `json:"children"`
38 | }
39 |
40 | // Blob is a single file in git branch
41 | type Blob struct {
42 | Entry `json:"-"`
43 | Base
44 |
45 | Name string `json:"name"`
46 | }
47 |
48 | // Tree returns a tree of files and folders
49 | func (b Branch) Tree(p string) (tree *Tree, err error) {
50 | // Fix path, must end with a / if it's not empty
51 | if len(p) > 1 && p[len(p)-1] != '/' {
52 | p += "/"
53 | }
54 |
55 | res, err := Command(b.repo.Path, "ls-tree", "-l", b.Name, p)
56 | if err != nil {
57 | return nil, err
58 | }
59 |
60 | returnValue := Tree{
61 | Base: Base{
62 | Kind: TreeKind,
63 | Branch: b,
64 | },
65 | Path: p,
66 | }
67 | lines := strings.Split(res.String(), "\n")
68 | for i := 0; i < len(lines); i++ {
69 | //
70 | parts := strings.Fields(strings.TrimSpace(lines[i]))
71 | if len(parts) < 5 {
72 | continue
73 | }
74 | mode := parts[0]
75 | _kind := parts[1]
76 | sha := parts[2]
77 | size, _ := strconv.ParseUint(parts[3], 10, 64)
78 | name := strings.Join(parts[4:], " ")
79 |
80 | // The real kind
81 | kind := BlobKind
82 | if _kind == "tree" {
83 | //tree
84 | kind = TreeKind
85 | returnValue.Children = append(returnValue.Children, Tree{
86 | Base: Base{
87 | Kind: kind,
88 | ID: sha,
89 | Branch: b,
90 | Mode: mode,
91 | Size: size,
92 | },
93 | Path: name,
94 | })
95 | continue
96 | }
97 |
98 | // blob
99 | returnValue.Children = append(returnValue.Children, Blob{
100 | Base: Base{
101 | Kind: kind,
102 | ID: sha,
103 | Branch: b,
104 | Mode: mode,
105 | Size: size,
106 | },
107 | Name: name,
108 | })
109 | }
110 |
111 | return &returnValue, nil
112 | }
113 |
--------------------------------------------------------------------------------
/pkg/images/picture.go:
--------------------------------------------------------------------------------
1 | package images
2 |
3 | import (
4 | "crypto/aes"
5 | "encoding/hex"
6 | "io/ioutil"
7 | "os"
8 | "path"
9 | "time"
10 |
11 | "github.com/lucat1/o2/pkg/log"
12 | "github.com/lucat1/o2/pkg/store"
13 | cache "github.com/patrickmn/go-cache"
14 | )
15 |
16 | var Cache = cache.New(time.Hour, time.Hour)
17 |
18 | func Init() {
19 | folder := store.GetConfig().Section("pictures").Key("directory").String()
20 | if _, err := os.Stat(folder); os.IsNotExist(err) {
21 | // create the pictures folder if it doesn't exists
22 | if err = os.Mkdir(folder, 0666); err != nil {
23 | log.Fatal().
24 | Err(err).
25 | Str("path", folder).
26 | Msg("Could not create the pictures folder")
27 | }
28 | }
29 | }
30 |
31 | func Get(hash string) (res []byte, err error) {
32 | if data, has := Cache.Get(hash); has {
33 | return data.([]byte), err
34 | }
35 | log.Debug().
36 | Str("hash", hash).
37 | Msg("Reading image from the filesystem")
38 |
39 | folder := store.GetConfig().Section("pictures").Key("directory").String()
40 | res, err = ioutil.ReadFile(path.Join(folder, hash+".jpeg"))
41 |
42 | // since we read the image we should now cache it to prevent further readings
43 | Cache.Add(hash, res, cache.DefaultExpiration)
44 |
45 | return
46 | }
47 |
48 | func Save(hash string, value []byte) (err error) {
49 | folder := store.GetConfig().Section("pictures").Key("directory").String()
50 | err = ioutil.WriteFile(path.Join(folder, hash+".jpeg"), value, 0666)
51 | if err != nil {
52 | return err
53 | }
54 |
55 | // add the image to the cache
56 | Cache.Set(hash, value, cache.DefaultExpiration)
57 |
58 | return
59 | }
60 |
61 | func Encrypt(image []byte) (res string, err error) {
62 | key := store.GetConfig().Section("pictures").Key("secret").String()
63 | c, err := aes.NewCipher([]byte(key))
64 | if err != nil {
65 | return "", err
66 | }
67 |
68 | out := make([]byte, len(image))
69 | c.Encrypt(out, []byte(image))
70 |
71 | return hex.EncodeToString(out), nil
72 | }
73 |
--------------------------------------------------------------------------------
/pkg/middleware/charset.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import "net/http"
4 |
5 | // Charser sets the content encoing for each page to utf-8
6 | func Charser(f http.Handler) http.Handler {
7 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8 | w.Header().Set("Content-Type", "charset=utf-8")
9 | f.ServeHTTP(w, r)
10 | })
11 | }
12 |
--------------------------------------------------------------------------------
/pkg/middleware/debug.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/lucat1/o2/pkg/log"
8 | )
9 |
10 | // Debug logs route response timings and request data in debug mode
11 | func Debug(f http.Handler) http.Handler {
12 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13 | startTime := time.Now()
14 | f.ServeHTTP(w, r)
15 | duration := time.Now().Sub(startTime)
16 | log.Info().
17 | Str("method", r.Method).
18 | Str("url", r.URL.Path).
19 | Int64("duration", duration.Milliseconds()).
20 | Msg("Handled request")
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/pkg/middleware/pex.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 |
7 | "github.com/kataras/muxie"
8 | "github.com/lucat1/o2/pkg/auth"
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/lucat1/o2/pkg/models"
11 | "github.com/lucat1/o2/pkg/pex"
12 | uuid "github.com/satori/go.uuid"
13 | )
14 |
15 | // MustPex checks if the authenticated user has access to the required permission
16 | // on the required resource
17 | func MustPex(scopes []string, fallback http.HandlerFunc) muxie.Wrapper {
18 | return func(f http.Handler) http.Handler {
19 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20 | resource := r.Context().Value(Resource).(uuid.UUID)
21 | user, ok := r.Context().Value(auth.AccountKey).(*models.User)
22 | log.Debug().
23 | Strs("scopes", scopes).
24 | Str("resource", resource.String()).
25 | Bool("logged", user != nil).
26 | Msg("Checking the user's permission for the required scopes")
27 |
28 | id := uuid.Nil
29 | if ok {
30 | id = user.UUID
31 | }
32 |
33 | if pex.Can(resource, id, scopes) {
34 | handle(w, r, true, f, fallback)
35 | return
36 | }
37 |
38 | // if the user/id cannot do that we ask them to log in again
39 | auth.Must(func(w http.ResponseWriter, r *http.Request) {
40 | // we have authentication inside of here
41 | user := r.Context().Value(auth.AccountKey).(*models.User)
42 | has := pex.Can(resource, user.UUID, scopes)
43 | handle(w, r, has, f, fallback)
44 | })(w, r)
45 | })
46 | }
47 | }
48 |
49 | func handle(w http.ResponseWriter, r *http.Request, has bool, f http.Handler, fallback http.HandlerFunc) {
50 | if has {
51 | f.ServeHTTP(w, r)
52 | } else if strings.Contains(r.Header.Get("User-Agent"), "git") {
53 | // send a forbidden status to git clients
54 | w.WriteHeader(http.StatusForbidden)
55 | } else {
56 | fallback(w, r)
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/pkg/middleware/repo.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "context"
5 | "net/http"
6 |
7 | "github.com/kataras/muxie"
8 | "github.com/lucat1/o2/pkg/git"
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/lucat1/o2/pkg/models"
11 | )
12 |
13 | type repoType string
14 |
15 | // DbRepo is the context key for the database repository value
16 | const DbRepo = repoType("db-repo")
17 |
18 | // GitRepo is the context key for the fs/git repository value
19 | const GitRepo = repoType("git-repo")
20 |
21 | // WithRepo checks for the existance of a repo both in the file system and in the database
22 | // and returns a 404 if they don't exist. Otherwhise these two values are available in the context
23 | func WithRepo(fallback http.HandlerFunc) muxie.Wrapper {
24 | return func(f http.Handler) http.Handler {
25 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26 | owner := muxie.GetParam(w, "name")
27 | reponame := muxie.GetParam(w, "repo")
28 | repository, err := models.GetRepositoryByName(owner, reponame)
29 | if err != nil {
30 | log.Debug().
31 | Err(err).
32 | Str("owner", owner).
33 | Str("name", reponame).
34 | Msg("Error while fetching repository page")
35 | fallback.ServeHTTP(w, r)
36 | return
37 | }
38 |
39 | gitRepository, err := git.Get(repository.UUID.String())
40 | if err != nil {
41 | log.Debug().
42 | Err(err).
43 | Str("uuid", repository.UUID.String()).
44 | Msg("Error while looking for git repository")
45 | fallback.ServeHTTP(w, r)
46 | return
47 | }
48 |
49 | // save the values in the context
50 | ctx := context.WithValue(r.Context(), DbRepo, repository)
51 | ctx = context.WithValue(ctx, GitRepo, gitRepository)
52 | f.ServeHTTP(w, r.WithContext(ctx))
53 | })
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/pkg/middleware/resource.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/kataras/muxie"
7 | "github.com/lucat1/o2/pkg/models"
8 | uuid "github.com/satori/go.uuid"
9 | "golang.org/x/net/context"
10 | )
11 |
12 | type resourceType string
13 |
14 | // Resource is the context key for requested resource
15 | const Resource = resourceType("resource")
16 |
17 | // ResourceGenerator is the type for the function that will generate the resource value
18 | type ResourceGenerator = func(w http.ResponseWriter, r http.Request) uuid.UUID
19 |
20 | // WithResource is a middleware used to generate a resource and put it in the context
21 | // for fturue handlers/middlewares to consume it. The resource value is generated by
22 | // the provided function
23 | func WithResource(gen ResourceGenerator) muxie.Wrapper {
24 | return func(f http.Handler) http.Handler {
25 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26 | ctx := context.WithValue(r.Context(), Resource, gen(w, *r))
27 | f.ServeHTTP(w, r.WithContext(ctx))
28 | })
29 | }
30 | }
31 |
32 | // RepositoryResource is the default resource generator for a repository
33 | var RepositoryResource ResourceGenerator = func(w http.ResponseWriter, r http.Request) uuid.UUID {
34 | repo := r.Context().Value(DbRepo).(models.Repository)
35 | return repo.UUID
36 | }
37 |
--------------------------------------------------------------------------------
/pkg/middleware/scheme.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "net/http"
5 | )
6 |
7 | // Scheme sets r.URL.Scheme if not defined already
8 | func Scheme(f http.Handler) http.Handler {
9 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
10 | if r.URL.Scheme == "" {
11 | r.URL.Scheme = "http"
12 | }
13 |
14 | f.ServeHTTP(w, r)
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/pkg/models/action.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/jmoiron/sqlx/types"
7 | "github.com/lucat1/o2/pkg/store"
8 | uuid "github.com/satori/go.uuid"
9 | )
10 |
11 | const (
12 | // CommitEvent is the type for a commit event
13 | CommitEvent = Type("commit")
14 |
15 | // CreateRepositoryEvent is the type for a create repository event
16 | CreateRepositoryEvent = Type("create-repository")
17 | )
18 |
19 | const insertEvent = `
20 | INSERT INTO events (
21 | created_at,
22 | updated_at,
23 | deleted_at,
24 |
25 | resource,
26 | time,
27 | type,
28 | data
29 | ) VALUES (
30 | ?, ?, ?,
31 | ?, ?, ?, ?
32 | )
33 | `
34 |
35 | const updateEvent = `
36 | UPDATE events SET
37 | created_at=?,
38 | updated_at=?,
39 | deleted_at=?,
40 |
41 | resource=?,
42 | time=?,
43 | type=?,
44 | data=?
45 | WHERE id=?
46 | `
47 |
48 | const selectVisibleEvents = `
49 | SELECT * FROM events e
50 | JOIN permissions p ON e.resource = p.resource
51 | JOIN repositories r ON e.resource = r.uuid
52 | WHERE (p.beneficiary = ? OR p.beneficiary = ?) AND p.scope = "repo:pull"
53 | ORDER BY time DESC LIMIT ? OFFSET ?
54 | `
55 |
56 | // Event is the database model for a git event
57 | type Event struct {
58 | Model
59 |
60 | Resource uuid.UUID `json:"-"`
61 | // inherit from repository
62 | OwnerName string `db:"owner_name" json:"owner"`
63 | Name string `json:"name"`
64 |
65 | Time time.Time `json:"time"`
66 | Type Type `json:"type"`
67 | Data types.JSONText `json:"data"`
68 | }
69 |
70 | // Insert inserts an event into the database
71 | func (event *Event) Insert() error {
72 | event.generate()
73 |
74 | // query the db
75 | res, err := store.GetDB().Exec(
76 | store.GetDB().Rebind(insertEvent),
77 | event.CreatedAt,
78 | event.UpdatedAt,
79 | event.DeletedAt,
80 |
81 | event.Resource,
82 | event.Time,
83 | event.Type,
84 | event.Data,
85 | )
86 | if err != nil {
87 | return err
88 | }
89 |
90 | event.ID, err = res.LastInsertId()
91 | return err
92 | }
93 |
94 | // Update updates an event struct in the database
95 | func (event Event) Update() error {
96 | // update updated_at time stamp
97 | event.UpdatedAt = time.Now()
98 |
99 | // query the db
100 | _, err := store.GetDB().Exec(
101 | store.GetDB().Rebind(updateEvent),
102 | event.CreatedAt,
103 | event.UpdatedAt,
104 | event.DeletedAt,
105 |
106 | event.Resource,
107 | event.Time,
108 | event.Type,
109 | event.Data,
110 |
111 | // where
112 | event.ID,
113 | )
114 |
115 | return err
116 | }
117 |
118 | // SelectVisileEvents returns a list of visible events for the given user
119 | // limited and offsetted eventually
120 | func SelectVisileEvents(beneficiary uuid.UUID, limit, offset int) (events []Event, err error) {
121 | err = store.GetDB().Unsafe().Select(
122 | &events,
123 | store.GetDB().Rebind(selectVisibleEvents),
124 | beneficiary, uuid.Nil, limit, offset*limit,
125 | )
126 | return
127 | }
128 |
--------------------------------------------------------------------------------
/pkg/models/base.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 |
6 | uuid "github.com/satori/go.uuid"
7 | )
8 |
9 | // Model implements the same struct as gorm.Model but hides all sql fields
10 | // from being exported into JSON
11 | type Model struct {
12 | ID int64 `json:"-"`
13 | CreatedAt time.Time `db:"created_at" json:"-"`
14 | UpdatedAt time.Time `db:"updated_at" json:"-"`
15 | DeletedAt *time.Time `db:"deleted_at" json:"-"`
16 | }
17 |
18 | func (model *Model) generate() {
19 | model.CreatedAt = time.Now()
20 | model.UpdatedAt = time.Now()
21 | }
22 |
23 | // Base recreates gorm.Model but with a UUID instead
24 | // of an ID. We generate that via satori/go.uuid
25 | type Base struct {
26 | UUID uuid.UUID `json:"-"`
27 | CreatedAt time.Time `db:"created_at" json:"-"`
28 | UpdatedAt time.Time `db:"updated_at" json:"-"`
29 | DeletedAt *time.Time `db:"deleted_at" json:"-"`
30 | }
31 |
32 | func (base *Base) generate() {
33 | base.UUID = uuid.NewV4()
34 | base.CreatedAt = time.Now()
35 | base.UpdatedAt = time.Now()
36 | }
37 |
--------------------------------------------------------------------------------
/pkg/models/issue_comment.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/lucat1/o2/pkg/store"
7 | uuid "github.com/satori/go.uuid"
8 | )
9 |
10 | const insertIssueComment = `
11 | INSERT INTO issue_comments (
12 | created_at,
13 | updated_at,
14 | deleted_at,
15 |
16 | issue,
17 | author,
18 | body
19 | ) VALUES (
20 | ?, ?, ?,
21 | ?, ?, ?
22 | )
23 | `
24 |
25 | const updateIssueComment = `
26 | UPDATE issue_comments SET (
27 | created_at=?,
28 | updated_at=?,
29 | deleted_at=?,
30 |
31 | issue=?,
32 | author=?,
33 | body=?
34 | WHERE id=?
35 | `
36 |
37 | const selectIssueComments = `
38 | SELECT i.*, u.uuid, u.picture, u.name FROM issue_comments i
39 | JOIN users u ON u.uuid = i.author
40 | WHERE issue=? AND i.deleted_at IS NULL
41 | `
42 |
43 | type IssueComment struct {
44 | ID int64 `json:"-"`
45 | CreatedAt time.Time `db:"created_at" json:"commented"`
46 | UpdatedAt time.Time `db:"updated_at" json:"edited"`
47 | DeletedAt *time.Time `db:"deleted_at" json:"-"`
48 |
49 | Issue int64 `json:"-"`
50 | Author uuid.UUID `json:"-"`
51 | Body string `json:"body"`
52 |
53 | Picture string `json:"picture"`
54 | Name string `json:"name"`
55 | }
56 |
57 | func (issueComment *IssueComment) Insert() error {
58 | issueComment.CreatedAt = time.Now()
59 | issueComment.UpdatedAt = time.Now()
60 |
61 | // query the db
62 | res, err := store.GetDB().Exec(
63 | store.GetDB().Rebind(insertIssueComment),
64 | issueComment.CreatedAt,
65 | issueComment.UpdatedAt,
66 | issueComment.DeletedAt,
67 |
68 | issueComment.Issue,
69 | issueComment.Author,
70 | issueComment.Body,
71 | )
72 | if err != nil {
73 | return err
74 | }
75 |
76 | issueComment.ID, err = res.LastInsertId()
77 | return err
78 | }
79 |
80 | func (issueComment IssueComment) Update() error {
81 | // update updated_at time stamp
82 | issueComment.UpdatedAt = time.Now()
83 |
84 | // query the db
85 | _, err := store.GetDB().Exec(
86 | store.GetDB().Rebind(updateIssueComment),
87 | issueComment.CreatedAt,
88 | issueComment.UpdatedAt,
89 | issueComment.DeletedAt,
90 |
91 | issueComment.Issue,
92 | issueComment.Author,
93 | issueComment.Body,
94 |
95 | // where
96 | issueComment.ID,
97 | )
98 |
99 | return err
100 | }
101 |
102 | // SelectIssueComments returns a list of issue comments inside the requested issue
103 | func SelectIssueComments(issue int) (comments []IssueComment, err error) {
104 | err = store.GetDB().Unsafe().Select(
105 | &comments,
106 | store.GetDB().Rebind(selectIssueComments),
107 | issue,
108 | )
109 | return
110 | }
111 |
--------------------------------------------------------------------------------
/pkg/models/organization.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/lucat1/o2/pkg/store"
7 | uuid "github.com/satori/go.uuid"
8 | )
9 |
10 | const addMapping = `
11 | INSERT INTO users_organizations (user_uuid, organization_uuid) VALUES (?, ?)
12 | `
13 |
14 | const delMapping = `
15 | DELETE FROM users_organizations WHERE user_uuid=? AND organization_uuid=?
16 | `
17 |
18 | const findUsersOrOrganizations = `
19 | SELECT * FROM users_organizations o JOIN users u ON o.%s_uuid = u.uuid WHERE o.%s_uuid = ?
20 | `
21 |
22 | // Add adds a user to the user<->organization mapping
23 | func (org User) Add(user User) error {
24 | _, err := store.GetDB().Exec(
25 | store.GetDB().Rebind(addMapping),
26 | user.UUID, org.UUID,
27 | )
28 | return err
29 | }
30 |
31 | // Del removes a user from the user<->organization mapping
32 | func (org User) Del(user User) error {
33 | _, err := store.GetDB().Exec(
34 | store.GetDB().Rebind(delMapping),
35 | user.UUID, org.UUID,
36 | )
37 | return err
38 | }
39 |
40 | // SelectMapping selects all users belonging to an organization and vice versa
41 | // behaviour:
42 | // key=user --> find all organizations
43 | // key=organization --> find all users
44 | func SelectMapping(key string, value uuid.UUID) (orgs []User, err error) {
45 | other := "user"
46 | if key == "user" {
47 | other = "organization"
48 | }
49 |
50 | // we need to use unsafe as some fields returned from
51 | // the JOIN are not used, and that's by design
52 | err = store.GetDB().Unsafe().Select(
53 | &orgs,
54 | fmt.Sprintf(findUsersOrOrganizations, other, key),
55 | value,
56 | )
57 | return
58 | }
59 |
--------------------------------------------------------------------------------
/pkg/models/permission.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "github.com/jmoiron/sqlx"
5 | "github.com/lucat1/o2/pkg/store"
6 | uuid "github.com/satori/go.uuid"
7 | )
8 |
9 | const insertPermission = `
10 | INSERT INTO permissions (
11 | beneficiary,
12 | resource,
13 | scope
14 | ) VALUES (
15 | ?, ?, ?
16 | )
17 | `
18 |
19 | const delPermission = `
20 | DELETE FROM permissions WHERE
21 | beneficiary=? AND resource=? AND scope=?
22 | `
23 |
24 | const findSinglePermission = `
25 | SELECT * FROM permissions WHERE beneficiary=? AND resource=? AND scope=?
26 | `
27 |
28 | const findResourcePermissions = `
29 | SELECT * FROM permissions WHERE resource=?
30 | `
31 |
32 | // Permission is the database model for a permission to acess a resource
33 | type Permission struct {
34 | Beneficiary uuid.UUID `json:"for"`
35 | Resource uuid.UUID `json:"resource"`
36 | Scope string `json:"scope"`
37 | }
38 |
39 | // Insert inserts a permission into the database
40 | func (permission Permission) Insert() error {
41 | // query the db
42 | _, err := store.GetDB().Exec(
43 | store.GetDB().Rebind(insertPermission),
44 | permission.Beneficiary,
45 | permission.Resource,
46 | permission.Scope,
47 | )
48 |
49 | return err
50 | }
51 |
52 | // Delete deletes this permission node in the database
53 | func (permission Permission) Delete() error {
54 | // query the db
55 | _, err := store.GetDB().Exec(
56 | store.GetDB().Rebind(delPermission),
57 | permission.Beneficiary,
58 | permission.Resource,
59 | permission.Scope,
60 | )
61 |
62 | return err
63 | }
64 |
65 | // GetPermission returns the permission for the
66 | // requested beneficiary & resource & scope
67 | func GetPermission(beneficiary uuid.UUID, resource uuid.UUID, scope string) (permission Permission, err error) {
68 | err = store.GetDB().Get(
69 | &permission,
70 | store.GetDB().Rebind(findSinglePermission+"LIMIT 1"),
71 | beneficiary, resource, scope,
72 | )
73 | return
74 | }
75 |
76 | // SelectPermissions returns a list of permissions for the requested resource and scopes
77 | func SelectPermissions(resource uuid.UUID, scopes []string) (permissions []Permission, err error) {
78 | query, args, err := sqlx.In(
79 | findResourcePermissions+"AND scope IN (?)",
80 | resource, scopes,
81 | )
82 | if err != nil {
83 | return permissions, err
84 | }
85 | err = store.GetDB().Select(
86 | &permissions,
87 | store.GetDB().Rebind(query),
88 | args...,
89 | )
90 | return
91 | }
92 |
93 | // FindPermissions returns a list of permissions for the requested resource & beneficiary & scopes
94 | func FindPermissions(resource uuid.UUID, beneficiary uuid.UUID, scopes []string) (permissions []Permission, err error) {
95 | query, args, err := sqlx.In(
96 | findResourcePermissions+"AND (beneficiary=? OR beneficiary=?) AND scope IN (?)",
97 | resource, beneficiary, uuid.Nil, scopes,
98 | )
99 | if err != nil {
100 | return permissions, err
101 | }
102 | err = store.GetDB().Select(
103 | &permissions,
104 | store.GetDB().Rebind(query),
105 | args...,
106 | )
107 | return
108 | }
109 |
--------------------------------------------------------------------------------
/pkg/pex/pex.go:
--------------------------------------------------------------------------------
1 | package pex
2 |
3 | import (
4 | "github.com/lucat1/o2/pkg/log"
5 | "github.com/lucat1/o2/pkg/models"
6 | uuid "github.com/satori/go.uuid"
7 | )
8 |
9 | // Can queries the database to check if a user can run an action
10 | func Can(resource uuid.UUID, who uuid.UUID, scopes []string) bool {
11 | pexes, err := models.FindPermissions(resource, who, scopes)
12 | if err != nil {
13 | log.Error().
14 | Err(err).
15 | Str("resource", resource.String()).
16 | Str("who", who.String()).
17 | Strs("scopes", scopes).
18 | Msg("Could not fetch permissions to check for access")
19 |
20 | // cannot be certain the user has permission. Say no in case of doubt
21 | return false
22 | }
23 |
24 | return len(pexes) == len(scopes)
25 | }
26 |
--------------------------------------------------------------------------------
/pkg/render/ogp.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 | )
7 |
8 | // SEO optimization and social media metadata provider
9 | // see: https://ogp.me/
10 |
11 | // OGPTag returns a og: prefixed meta tag
12 | func OGPTag(property string, value string) string {
13 | return ``
14 | }
15 |
16 | // ProfileTag returns a profile: prefixed meta tag (Open Graph Protocol)
17 | func ProfileTag(property string, value string) string {
18 | return ``
19 | }
20 |
21 | // TwitterTag returns a twitter: prefixed meta tag
22 | func TwitterTag(property string, value string) string {
23 | return ``
24 | }
25 |
26 | // SEO returns a skeleton HTML containing only a head tag with SEO metadata
27 | func SEO(w http.ResponseWriter, r *http.Request, tags ...string) {
28 | tags = append([]string{
29 | OGPTag("url", r.URL.Scheme+"://"+r.Host+r.URL.Path),
30 | OGPTag("locale", "en"),
31 | OGPTag("site_name", "o2"),
32 | OGPTag("type", "website"),
33 | // TODO: move the logo somewhere static
34 | // OGPTag("image", ""),
35 | TwitterTag("card", "summary"),
36 | }, tags...)
37 |
38 | w.Write([]byte(`` + strings.Join(tags, "") + ``))
39 | }
40 |
--------------------------------------------------------------------------------
/pkg/render/render.go:
--------------------------------------------------------------------------------
1 | package render
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/data"
7 | "github.com/lucat1/quercia"
8 | "github.com/omohayui/crawlerdetector"
9 | )
10 |
11 | // Result is a struct to hold the render result for a page
12 | type Result struct {
13 | Redirect string
14 | Page string
15 | Tags []string
16 | Composers []data.Composer
17 | }
18 |
19 | var detector *crawlerdetector.CrawlerDetector
20 |
21 | func init() {
22 | detector = crawlerdetector.New()
23 | }
24 |
25 | // Renderer is a function that returns a renderer "context"
26 | type Renderer func(writer http.ResponseWriter, request *http.Request) Result
27 |
28 | // Render renders a page with the given renderer
29 | func Render(w http.ResponseWriter, r *http.Request, renderer Renderer) {
30 | res := renderer(w, r)
31 |
32 | if detector.IsCrawler(r.Header.Get("User-Agent")) {
33 | SEO(w, r, res.Tags...)
34 | return
35 | }
36 |
37 | composers := append([]data.Composer{data.Base}, res.Composers...)
38 | if len(res.Redirect) > 0 {
39 | quercia.Redirect(w, r, res.Redirect, res.Page, data.Compose(r, composers...))
40 | } else {
41 | quercia.Render(w, r, res.Page, data.Compose(r, composers...))
42 | }
43 | }
44 |
45 | // WithRedirect returns a copy of the given result with a redirect field
46 | func WithRedirect(result Result, to string) Result {
47 | result.Redirect = to
48 | return result
49 | }
50 |
--------------------------------------------------------------------------------
/pkg/store/config.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "flag"
5 | "io/ioutil"
6 | "os"
7 | "path"
8 |
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/markbates/pkger"
11 | "gopkg.in/ini.v1"
12 | )
13 |
14 | var (
15 | config *ini.File
16 |
17 | // ConfigPath is the path of the configuration file
18 | ConfigPath *string
19 | )
20 |
21 | // load the configuration file
22 | func init() {
23 | ConfigPath = flag.String("config", "data/o2.ini", "The path to the configuration file")
24 | }
25 |
26 | // InitConfig loads the configuration file and saves it into memory
27 | func InitConfig() {
28 | // make the path absolute by resolving it against the process cwd
29 | if !path.IsAbs(*ConfigPath) {
30 | *ConfigPath = path.Join(cwd, *ConfigPath)
31 | }
32 |
33 | // initialize a basic config if none is provided
34 | if _, err := os.Stat(*ConfigPath); err != nil {
35 | file, _ := pkger.Open("/data/o2.ini")
36 | contents, _ := ioutil.ReadAll(file)
37 | if err = ioutil.WriteFile(*ConfigPath, contents, 0644); err != nil {
38 | log.Fatal().Err(err).Msg("Could not create default configuration")
39 | }
40 | }
41 |
42 | cfg, err := ini.Load(*ConfigPath)
43 | if err != nil {
44 | log.Fatal().Err(err).Msg("Could not laod configuration file")
45 | }
46 |
47 | config = cfg
48 | }
49 |
50 | // GetConfig returns the o2 service configuration file
51 | func GetConfig() *ini.File {
52 | return config
53 | }
54 |
--------------------------------------------------------------------------------
/pkg/store/cwd.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/lucat1/o2/pkg/log"
7 | )
8 |
9 | var cwd string
10 |
11 | func init() {
12 | wd, err := os.Getwd()
13 | if err != nil {
14 | log.Fatal().Err(err).Msg("Could not get current working directory")
15 | }
16 |
17 | cwd = wd
18 | }
19 |
20 | // GetCwd returns the current working directory of the service process
21 | func GetCwd() string {
22 | return cwd
23 | }
24 |
--------------------------------------------------------------------------------
/pkg/store/database.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "database/sql"
5 |
6 | "github.com/jmoiron/sqlx"
7 | "github.com/lucat1/o2/pkg/log"
8 | sqldblogger "github.com/simukti/sqldb-logger"
9 | "github.com/simukti/sqldb-logger/logadapter/zerologadapter"
10 |
11 | // mysql driver for sql(x)
12 | _ "github.com/go-sql-driver/mysql"
13 |
14 | // sqlite driver for sql(x)
15 | _ "github.com/mattn/go-sqlite3"
16 |
17 | // postgres driver for sql(x)
18 | _ "github.com/lib/pq"
19 | )
20 |
21 | var database *sqlx.DB
22 |
23 | // InitDatabase initializes the database connection
24 | func InitDatabase() {
25 | dialect := config.Section("database").Key("dialect").String()
26 | uri := config.Section("database").Key("uri").String()
27 | log.Debug().
28 | Str("dialect", dialect).
29 | Str("uri", uri).
30 | Msg("Opening to database")
31 |
32 | // connection URI format: user:password@(localhost)/dbname?charset=utf8&parseTime=True&loc=Local
33 | db, err := sql.Open(dialect, uri)
34 | if err != nil {
35 | log.Fatal().Err(err).Msg("Could not parse connection URI")
36 | }
37 | if err = db.Ping(); err != nil {
38 | log.Fatal().Err(err).Msg("Could not connect to database")
39 | }
40 |
41 | loggerAdapter := zerologadapter.New(log.Logger)
42 | db = sqldblogger.OpenDriver(
43 | uri, db.Driver(), loggerAdapter,
44 | sqldblogger.WithQueryerLevel(sqldblogger.LevelDebug),
45 | sqldblogger.WithPreparerLevel(sqldblogger.LevelDebug),
46 | sqldblogger.WithExecerLevel(sqldblogger.LevelDebug),
47 | )
48 |
49 | log.Info().Msg("Successfully connected to the database")
50 | database = sqlx.NewDb(db, dialect)
51 | }
52 |
53 | // GetDB returns the current database instance
54 | func GetDB() *sqlx.DB {
55 | return database
56 | }
57 |
58 | // CloseDB closes the connection to the database
59 | func CloseDB() error {
60 | return database.Close()
61 | }
62 |
--------------------------------------------------------------------------------
/pkg/store/hooks.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "os"
5 | "os/exec"
6 |
7 | "github.com/lucat1/o2/pkg/log"
8 | )
9 |
10 | var (
11 | // PostReceiveHook is the path to the post-receive-hook command
12 | PostReceiveHook string
13 | )
14 |
15 | // InitHooks initializes the hooks paths
16 | func InitHooks() {
17 | output, err := exec.Command("which", "o2-post-receive").Output()
18 | if err != nil {
19 | log.Fatal().
20 | Err(err).
21 | Str("path", os.Getenv("PATH")).
22 | Msg("Could not find `o2-post-receive` hook")
23 | }
24 | if len(output) == 0 {
25 | log.Fatal().
26 | Str("path", os.Getenv("PATH")).
27 | Bytes("output", output).
28 | Msg("Could not find `o2-post-receive` hook")
29 | }
30 |
31 | PostReceiveHook = string(output)[:len(output)-1]
32 | log.Debug().
33 | Str("post-receive", PostReceiveHook).
34 | Msg("Found hooks")
35 | }
36 |
--------------------------------------------------------------------------------
/pkg/store/log.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "flag"
5 | "io"
6 | "os"
7 | "path"
8 |
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/rs/zerolog"
11 | )
12 |
13 | var (
14 | // HooksLogsPath is the path to the hooks logs
15 | HooksLogsPath string
16 | debug *bool
17 | )
18 |
19 | func init() {
20 | debug = flag.Bool("debug", false, "Set the loglevel to debug")
21 | }
22 |
23 | // InitLogs initialzes the custom zerolog logger
24 | func InitLogs() {
25 | // Resolve the path for the hooks logs used by git hooks
26 | HooksLogsPath = config.Section("o2").Key("hooks_log").String()
27 | if !path.IsAbs(HooksLogsPath) {
28 | HooksLogsPath = path.Join(cwd, HooksLogsPath)
29 | }
30 |
31 | // UNIX Time is faster and smaller than most timestamps
32 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
33 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
34 |
35 | // setup a file-based and a stdout logger
36 | file, err := os.OpenFile(
37 | config.Section("o2").Key("log").String(),
38 | os.O_APPEND|os.O_CREATE|os.O_WRONLY,
39 | 0644,
40 | )
41 | if err == nil {
42 | log.Output(io.MultiWriter(os.Stderr, file))
43 | } else {
44 | log.Debug().Err(err).Msg("Could not open log file, ignoring")
45 | }
46 |
47 | if *debug {
48 | log.Debug().Msg("Loglevel set to debug")
49 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/quercia.config.js:
--------------------------------------------------------------------------------
1 | const { join } = require('path')
2 | const externals = require('webpack-node-externals')
3 |
4 | module.exports = function({ config, target, mode }) {
5 | config.module.rules.push({
6 | test: /\.(woff2)$/i,
7 | loader: 'file-loader',
8 | options: {
9 | // set a static public path so that both the client
10 | // and the server can have the same url for the file
11 | publicPath: join('__quercia', 'client')
12 | }
13 | })
14 |
15 | if (target === 'client') {
16 | // for production use `preact` instead of `react` to save bytes
17 | if (mode === 'production') {
18 | config.resolve.alias = {
19 | ...config.resolve.alias,
20 | 'react': 'preact/compat',
21 | 'react-dom': 'preact/compat'
22 | }
23 | }
24 | }
25 |
26 | if (target == 'server') {
27 | // don't treat `promisify-file-reader` as an external module
28 | config.externals[config.externals.length - 1] = externals({
29 | whitelist: ['promisify-file-reader']
30 | })
31 |
32 | // then alias it to a noop module
33 | config.resolve.alias = {
34 | ...config.resolve.alias,
35 | 'promisify-file-reader': require.resolve('@quercia/cli/dist/webpack/noop')
36 | }
37 | }
38 |
39 | return config
40 | }
41 |
--------------------------------------------------------------------------------
/routes/datas/blob.go:
--------------------------------------------------------------------------------
1 | package datas
2 |
3 | import (
4 | "net/http"
5 | "path"
6 |
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/git"
9 | "github.com/lucat1/quercia"
10 | )
11 |
12 | // BlobData composes the data for the blob page
13 | func BlobData(blob git.Blob, data string) data.Composer {
14 | return func(r *http.Request) quercia.Props {
15 | ext := path.Ext(blob.Name)
16 | if len(ext) > 1 {
17 | ext = ext[1:] // trim trailing .
18 | }
19 |
20 | return quercia.Props{
21 | "blob": blob,
22 | "data": data,
23 | "ext": ext,
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/routes/datas/repository.go:
--------------------------------------------------------------------------------
1 | package datas
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/auth"
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/models"
9 | "github.com/lucat1/o2/pkg/pex"
10 | "github.com/lucat1/quercia"
11 | )
12 |
13 | // RepositoryData composes the data for a repository view
14 | func RepositoryData(repo models.Repository) data.Composer {
15 | return func(r *http.Request) quercia.Props {
16 | canPush := false
17 | if auth.IsAuthenticated(r) {
18 | claims := r.Context().Value(auth.ClaimsKey).(*auth.Claims)
19 | canPush = pex.Can(repo.UUID, claims.UUID, []string{"repo:push"})
20 | }
21 |
22 | return quercia.Props{
23 | "repository": repo,
24 | "owns": canPush,
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/routes/git/cache_headers.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "net/http"
5 | "time"
6 | )
7 |
8 | func headerNoCache(w http.ResponseWriter) {
9 | w.Header().Add("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
10 | w.Header().Set("Pragma", "no-cache")
11 | w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
12 | }
13 |
14 | func headerCacheForever(w http.ResponseWriter) {
15 | now := time.Now()
16 | w.Header().Set("Date", now.String())
17 | w.Header().Set("Expires", now.Add(31536000).String())
18 | w.Header().Set("Cache-Control", "public, max-age=31536000")
19 | }
20 |
--------------------------------------------------------------------------------
/routes/git/get_service_type.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 | )
7 |
8 | func getServiceType(r *http.Request) string {
9 | serviceType := r.FormValue("service")
10 |
11 | if !strings.HasPrefix(serviceType, "git-") {
12 | return ""
13 | }
14 |
15 | return strings.Replace(serviceType, "git-", "", 1)
16 | }
17 |
--------------------------------------------------------------------------------
/routes/git/git_command.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "os"
5 | "os/exec"
6 |
7 | "github.com/lucat1/o2/pkg/log"
8 | )
9 |
10 | // FIXME: TODO: Maybe just merge this and lots of other functions with the git package??
11 |
12 | func gitCommand(dir string, version string, args ...string) []byte {
13 | command := exec.Command("git", args...)
14 | if len(version) > 0 {
15 | command.Env = append(os.Environ(), "GIT_PROTOCOL="+version)
16 | }
17 | command.Dir = dir
18 | out, err := command.Output()
19 |
20 | if err != nil {
21 | log.Error().Strs("args", args).Str("verion", version).Str("dir", dir).Msg("Error while executing git smart http command")
22 | }
23 |
24 | return out
25 | }
26 |
--------------------------------------------------------------------------------
/routes/git/internal_error.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import "net/http"
4 |
5 | // serves a 500 error with a standard message
6 | func internalError(w http.ResponseWriter, r *http.Request) {
7 | w.WriteHeader(http.StatusInternalServerError)
8 | w.Write([]byte("Internal server error"))
9 | }
10 |
--------------------------------------------------------------------------------
/routes/git/packets.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "strconv"
5 | "strings"
6 | )
7 |
8 | func packetWrite(str string) []byte {
9 | s := strconv.FormatInt(int64(len(str)+4), 16)
10 |
11 | if len(s)%4 != 0 {
12 | s = strings.Repeat("0", 4-len(s)%4) + s
13 | }
14 |
15 | return []byte(s + str)
16 | }
17 |
18 | func packetFlush() []byte {
19 | return []byte("0000")
20 | }
--------------------------------------------------------------------------------
/routes/git/refs.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/kataras/muxie"
7 | "github.com/lucat1/o2/pkg/git"
8 | "github.com/lucat1/o2/pkg/log"
9 | "github.com/lucat1/o2/pkg/middleware"
10 | "github.com/lucat1/o2/pkg/models"
11 | "github.com/lucat1/o2/routes/shared"
12 | )
13 |
14 | // InfoRefs handles the request for the repository's
15 | // refs, branch names and commit hashes
16 | func InfoRefs(w http.ResponseWriter, r *http.Request) {
17 | if r.Method != http.MethodGet {
18 | shared.NotFound(w, r)
19 | return
20 | }
21 |
22 | dbRepo := r.Context().Value(middleware.DbRepo).(models.Repository)
23 | name := muxie.GetParam(w, "name")
24 | repo := muxie.GetParam(w, "repo")
25 | log.Debug().
26 | Str("name", name).
27 | Str("repo", repo).
28 | Msg("Handling git info refs")
29 |
30 | dir := git.GetPath(dbRepo.UUID.String())
31 | serviceName := getServiceType(r)
32 | version := r.Header.Get("Git-Protocol")
33 |
34 | //if access {
35 | refs := gitCommand(dir, version, serviceName, "--stateless-rpc", "--advertise-refs", dir)
36 |
37 | headerNoCache(w)
38 | w.Header().Add("Content-Type", "application/x-git-"+serviceName+"-advertisement")
39 | w.WriteHeader(http.StatusOK)
40 | if len(version) == 0 {
41 | // provide a valid git version if none is provided
42 | w.Write(packetWrite("# service=git-" + serviceName + "\n"))
43 | w.Write(packetFlush())
44 | }
45 | w.Write(refs)
46 | //} else {
47 | // updateServerInfo(dir)
48 | // hdrNocache(c)
49 | // sendFile("text/plain; charset=utf-8", c)
50 | //}
51 | }
52 |
--------------------------------------------------------------------------------
/routes/git/rpc.go:
--------------------------------------------------------------------------------
1 | package git
2 |
3 | import (
4 | "compress/gzip"
5 | "io"
6 | "net/http"
7 | "os"
8 | "os/exec"
9 |
10 | "github.com/kataras/muxie"
11 | "github.com/lucat1/o2/pkg/git"
12 | "github.com/lucat1/o2/pkg/log"
13 | "github.com/lucat1/o2/pkg/middleware"
14 | "github.com/lucat1/o2/pkg/models"
15 | "github.com/lucat1/o2/pkg/store"
16 | "github.com/lucat1/o2/routes/shared"
17 | )
18 |
19 | // RPC is a route which spawns a git headless service
20 | // to accept/provide incoming/outgoing packets for git
21 | // repository poshing/pulling
22 | func RPC(rpc string) func(http.ResponseWriter, *http.Request) {
23 | return func(w http.ResponseWriter, r *http.Request) {
24 | if r.Method != http.MethodPost {
25 | shared.NotFound(w, r)
26 | return
27 | }
28 |
29 | dbRepo := r.Context().Value(middleware.DbRepo).(models.Repository)
30 | name := muxie.GetParam(w, "name")
31 | repo := muxie.GetParam(w, "repo")
32 | log.Debug().
33 | Str("name", name).
34 | Str("repo", repo).
35 | Str("rpc", rpc).
36 | Msg("Handling git headless rpc")
37 |
38 | // preset headers for to make the request valid
39 | w.Header().Set("Content-Type", "application/x-git-"+rpc+"-result")
40 | w.Header().Set("Connection", "Keep-Alive")
41 | w.Header().Set("Transfer-Encoding", "chunked")
42 | w.Header().Set("X-Content-Type-Options", "nosniff")
43 | w.WriteHeader(http.StatusOK)
44 |
45 | // build the git headless rpc command
46 | dir := git.GetPath(dbRepo.UUID.String())
47 | cmd := exec.Command("git", rpc, "--stateless-rpc", dir)
48 | cmd.Env = os.Environ()
49 |
50 | // add hooks values environment variables
51 | cmd.Env = append(
52 | cmd.Env,
53 | "O2_POST_RECEIVE="+store.PostReceiveHook,
54 | "CONFIGPATH="+*store.ConfigPath,
55 | "LOGSPATH="+store.HooksLogsPath,
56 | )
57 |
58 | if protocol := r.Header.Get("Git-Protocol"); protocol != "" {
59 | cmd.Env = append(cmd.Env, "GIT_PROTOCOL="+protocol)
60 | }
61 |
62 | in, err := cmd.StdinPipe()
63 | if err != nil {
64 | log.Error().Err(err).Msg("Could not open git rpc stdin")
65 | internalError(w, r)
66 | return
67 | }
68 |
69 | out, err := cmd.StdoutPipe()
70 | if err != nil {
71 | log.Error().Err(err).Msg("Could not open git rpc stdout")
72 | internalError(w, r)
73 | return
74 | }
75 |
76 | err = cmd.Start()
77 | if err != nil {
78 | log.Error().Err(err).Msg("Error while running the git rpc service")
79 | internalError(w, r)
80 | return
81 | }
82 |
83 | var reader io.ReadCloser
84 | switch r.Header.Get("Content-Encoding") {
85 | case "gzip":
86 | reader, err = gzip.NewReader(r.Body)
87 | defer reader.Close()
88 | default:
89 | reader = r.Body
90 | }
91 |
92 | // Write the request body to the git service
93 | io.Copy(in, reader)
94 | in.Close()
95 |
96 | flusher, ok := w.(http.Flusher)
97 | if !ok {
98 | log.Error().Msg("expected http.ResponseWriter to be an http.Flusher while serving git rpc")
99 | return
100 | }
101 |
102 | p := make([]byte, 1024)
103 | for {
104 | nRead, err := out.Read(p)
105 | if err == io.EOF {
106 | break
107 | }
108 |
109 | nWrite, err := w.Write(p[:nRead])
110 | if err != nil {
111 | log.Error().Err(err).Msg("Could not write to response in git rpc")
112 | return
113 | }
114 |
115 | if nRead != nWrite {
116 | log.Error().
117 | Int("read", nRead).
118 | Int("written", nWrite).
119 | Msg("The umber of bytes read does not match the number of bytes written")
120 | return
121 | }
122 |
123 | flusher.Flush()
124 | }
125 |
126 | cmd.Wait()
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/routes/index.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/kataras/muxie"
8 | "github.com/lucat1/o2/pkg/auth"
9 | "github.com/lucat1/o2/pkg/data"
10 | "github.com/lucat1/o2/pkg/models"
11 | "github.com/lucat1/o2/pkg/render"
12 | "github.com/lucat1/o2/routes/shared"
13 | "github.com/rs/zerolog/log"
14 | uuid "github.com/satori/go.uuid"
15 | )
16 |
17 | // FeedRenderer fetches the database for the user's feed and
18 | // returns the page and data to render
19 | var FeedRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
20 | // find the page, support request parameter
21 | s := muxie.GetParam(w, "page")
22 | var page int
23 | if len(s) > 0 {
24 | p, err := strconv.Atoi(s)
25 | if err != nil {
26 | return shared.NotFoundRenderer(w, r)
27 | }
28 | page = p
29 | } else {
30 | page = 0
31 | }
32 |
33 | user := uuid.Nil
34 | if auth.IsAuthenticated(r) {
35 | user = r.Context().Value(auth.ClaimsKey).(*auth.Claims).UUID
36 | }
37 |
38 | events, err := models.SelectVisileEvents(user, 20, page)
39 | if err != nil {
40 | log.Error().
41 | Err(err).
42 | Str("user", user.String()).
43 | Int("page", page).
44 | Msg("Could not get list of events")
45 |
46 | return shared.NotFoundRenderer(w, r)
47 | }
48 |
49 | title := "A tiny and fast Git web ui"
50 | desc := "Work on code together with your team using a fast and seamless Git web interface"
51 | return render.Result{
52 | Page: "feed",
53 | Tags: []string{
54 | render.OGPTag("title", title),
55 | render.TwitterTag("title", title),
56 | render.OGPTag("description", desc),
57 | render.TwitterTag("description", desc),
58 | },
59 | Composers: []data.Composer{data.WithAny("events", events)},
60 | }
61 | }
62 |
63 | // Feed renders a feed of git events in the homepage
64 | func Feed(w http.ResponseWriter, r *http.Request) {
65 | render.Render(w, r, FeedRenderer)
66 | }
67 |
--------------------------------------------------------------------------------
/routes/login.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/auth"
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/models"
9 | "github.com/lucat1/o2/pkg/render"
10 | )
11 |
12 | // LoginRenderer returns the page and the render data for login
13 | var LoginRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
14 | // ignore already logged-in users - redirect to home(feed)
15 | if auth.IsAuthenticated(r) {
16 | return render.WithRedirect(FeedRenderer(w, r), "/")
17 | }
18 |
19 | desc := "Login into the o2 platform; here you can" +
20 | " work on code together with your team using a fast and" +
21 | "seamless Git web interface"
22 | if r.Method != http.MethodPost {
23 | return render.Result{
24 | Page: "login",
25 | Tags: []string{
26 | render.OGPTag("title", "Login"),
27 | render.TwitterTag("title", "Login"),
28 | render.OGPTag("description", desc),
29 | render.TwitterTag("description", desc),
30 | },
31 | }
32 | }
33 |
34 | r.ParseMultipartForm(1 * 1024 * 1024 /* 1mb */)
35 | email := r.Form.Get("email")
36 | password := r.Form.Get("password")
37 |
38 | if email == "" || password == "" {
39 | return render.Result{
40 | Page: "login",
41 | Composers: []data.Composer{data.WithAny("error", "Please fill in all the required fields")},
42 | }
43 | }
44 |
45 | user := models.User{
46 | Email: email,
47 | Password: password,
48 | }
49 | token, err := auth.Login(&user)
50 | if err != nil {
51 | return render.Result{
52 | Page: "login",
53 | Composers: []data.Composer{data.WithAny("error", err.Error())},
54 | }
55 | }
56 |
57 | auth.SetCookie(w, r, token)
58 | if to := r.URL.Query().Get("to"); len(to) > 0 {
59 | return render.Result{
60 | Redirect: to,
61 | }
62 | }
63 |
64 | return render.WithRedirect(FeedRenderer(w, r), "/")
65 | }
66 |
67 | // Login renders the login page and handles authentication
68 | func Login(w http.ResponseWriter, r *http.Request) {
69 | render.Render(w, r, LoginRenderer)
70 | }
71 |
--------------------------------------------------------------------------------
/routes/logout.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/lucat1/o2/pkg/auth"
9 | "github.com/lucat1/o2/pkg/render"
10 | )
11 |
12 | // LogoutRenderer returns the page and the render data to logout a user
13 | var LogoutRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
14 | return render.WithRedirect(FeedRenderer(w, r), "/")
15 | }
16 |
17 | // Logout deletes the `token` cookie and redirects the user to the index page
18 | func Logout(w http.ResponseWriter, r *http.Request) {
19 | // unset the cookie
20 | http.SetCookie(w, &http.Cookie{
21 | Name: "token",
22 | Value: "",
23 | Expires: time.Now(),
24 | MaxAge: 0,
25 | })
26 | *r = *r.WithContext(context.WithValue(r.Context(), auth.ClaimsKey, nil))
27 |
28 | render.Render(w, r, LogoutRenderer)
29 | }
30 |
--------------------------------------------------------------------------------
/routes/new-org.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/data"
7 | "github.com/lucat1/o2/pkg/log"
8 | "github.com/lucat1/o2/pkg/models"
9 | "github.com/lucat1/o2/pkg/render"
10 | )
11 |
12 | func newOrg(w http.ResponseWriter, r *http.Request, user models.User) render.Result {
13 | orgname := r.Form.Get("name")
14 | _, err := models.GetUser("name", orgname)
15 | if err == nil {
16 | return render.Result{
17 | Page: "new",
18 | Composers: []data.Composer{
19 | data.WithAny("error", "This name is already taken"),
20 | },
21 | }
22 | }
23 |
24 | org := models.User{
25 | Type: models.OrganizationType,
26 | Name: orgname,
27 | }
28 |
29 | // save the org
30 | if err := org.Insert(); err != nil {
31 | goto fatal
32 | }
33 |
34 | // assign the user to the org
35 | if err := org.Add(user); err != nil {
36 | goto fatal
37 | }
38 |
39 | return render.Result{
40 | Page: "organization",
41 | Redirect: "/" + org.Name,
42 | Composers: []data.Composer{
43 | data.WithAny("profile", org),
44 | },
45 | }
46 |
47 | fatal:
48 | log.Error().
49 | Err(err).
50 | Str("owner", user.Name).
51 | Str("orgname", orgname).
52 | Msg("Could not save new organization in the database")
53 |
54 | return render.Result{
55 | Page: "new",
56 | Composers: []data.Composer{
57 | data.WithAny("error", "Internal error. Please try again layer"),
58 | },
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/routes/new-repo.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/git"
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/lucat1/o2/pkg/models"
11 | "github.com/lucat1/o2/pkg/render"
12 | "github.com/lucat1/o2/routes/datas"
13 |
14 | uuid "github.com/satori/go.uuid"
15 | )
16 |
17 | func newRepo(w http.ResponseWriter, r *http.Request, owner string, extra *uuid.UUID) render.Result {
18 | reponame := r.Form.Get("name")
19 | user, err := models.GetUser("name", owner)
20 | if err != nil {
21 | log.Error().
22 | Err(err).
23 | Str("owner", owner).
24 | Str("reponame", reponame).
25 | Msg("Could not find new repository owner")
26 |
27 | return render.Result{
28 | Page: "new",
29 | Composers: []data.Composer{
30 | data.WithAny("error", "Internal error. Please try again layer"),
31 | },
32 | }
33 | }
34 |
35 | re, err := models.GetRepository(user.UUID, reponame)
36 | if err == nil && re.UUID != uuid.Nil {
37 | return render.Result{
38 | Page: "new",
39 | Composers: []data.Composer{
40 | data.WithAny("error", "You already own a repository with this name"),
41 | },
42 | }
43 | }
44 |
45 | repo := models.Repository{
46 | OwnerUUID: user.UUID,
47 | OwnerName: user.Name,
48 | Name: reponame,
49 | }
50 | event := models.Event{
51 | Time: time.Now(),
52 | Type: models.CreateRepositoryEvent,
53 | }
54 |
55 | if err = repo.Insert(); err != nil {
56 | goto fatal
57 | }
58 |
59 | // don't log the error as this is not mandatory for the creation of a repository
60 | event.Resource = repo.UUID
61 | if err = event.Insert(); err != nil {
62 | log.Debug().
63 | Err(err).
64 | Str("resource", repo.UUID.String()).
65 | Msg("Could not create a new event for repository creation")
66 | }
67 |
68 | // add permissions
69 | if err = repo.Add(models.Permission{
70 | Beneficiary: uuid.Nil,
71 | Scope: "repo:pull",
72 | }); err != nil {
73 | goto fatal
74 | }
75 |
76 | if err = repo.Add(models.Permission{
77 | Beneficiary: user.UUID,
78 | Scope: "repo:push",
79 | }); err != nil {
80 | goto fatal
81 | }
82 |
83 | // add extra push permissions to the user
84 | if extra != nil {
85 | if err = repo.Add(models.Permission{
86 | Beneficiary: *extra,
87 | Scope: "repo:push",
88 | }); err != nil {
89 | goto fatal
90 | }
91 | }
92 |
93 | if _, err := git.Init(repo.UUID.String()); err != nil {
94 | log.Error().
95 | Str("owner", user.UUID.String()).
96 | Str("reponame", reponame).
97 | Str("repoUUID", repo.UUID.String()).
98 | Err(err).
99 | Msg("Could not initialize a bare git repository")
100 |
101 | goto fatal
102 | }
103 |
104 | return render.Result{
105 | Redirect: "/" + user.Name + "/" + reponame,
106 | Page: "repository/repository",
107 | Composers: []data.Composer{
108 | datas.RepositoryData(repo),
109 | data.WithAny("tree", nil),
110 | },
111 | }
112 |
113 | fatal:
114 | log.Error().
115 | Err(err).
116 | Str("owner", user.UUID.String()).
117 | Str("reponame", reponame).
118 | Msg("Could not save new repository in the database")
119 |
120 | return render.Result{
121 | Page: "new",
122 | Composers: []data.Composer{
123 | data.WithAny("error", "Internal error. Please try again layer"),
124 | },
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/routes/new.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/auth"
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/log"
9 | "github.com/lucat1/o2/pkg/models"
10 | "github.com/lucat1/o2/pkg/render"
11 | )
12 |
13 | // NewRenderer returns the page and the render data for both the new pages available interactions
14 | func NewRenderer(w http.ResponseWriter, r *http.Request) render.Result {
15 | user := r.Context().Value(auth.AccountKey).(*models.User)
16 | if user == nil {
17 | return render.WithRedirect(LoginRenderer(w, r), "/login?to="+r.URL.Path)
18 | }
19 |
20 | if r.Method != http.MethodPost {
21 | orgs, err := models.SelectMapping("user", user.UUID)
22 | if err != nil {
23 | log.Debug().
24 | Err(err).
25 | Str("user", user.UUID.String()).
26 | Msg("Could not find user's oganizations")
27 | }
28 |
29 | desc := "Create a new repository or organization on the o2 platform"
30 | return render.Result{
31 | Page: "new",
32 | Tags: []string{
33 | render.OGPTag("title", "New"),
34 | render.TwitterTag("title", "New"),
35 | render.OGPTag("description", desc),
36 | render.TwitterTag("description", desc),
37 | },
38 | Composers: []data.Composer{data.WithAny("user", user),
39 | data.WithAny("organizations", orgs),
40 | },
41 | }
42 | }
43 |
44 | // get all the mandatory data
45 | r.ParseMultipartForm(1 * 1024 * 1024 /* 1mb */)
46 | kind := r.FormValue("kind")
47 | owner := r.FormValue("owner")
48 |
49 | var result render.Result
50 | switch kind {
51 | case "repository":
52 | // if the user is creating a repository for another org
53 | // we give the creator push permissions
54 | if owner != user.Name {
55 | result = newRepo(w, r, owner, &user.UUID)
56 | } else {
57 | result = newRepo(w, r, owner, nil)
58 | }
59 | break
60 |
61 | case "organization":
62 | result = newOrg(w, r, *user)
63 | break
64 |
65 | default:
66 | result = render.Result{
67 | Page: "new",
68 | Composers: []data.Composer{data.WithAny("error", "Invalid new type of resource")},
69 | }
70 | }
71 |
72 | return result
73 | }
74 |
75 | // New prompts the user to create a new repository or an organization
76 | func New(w http.ResponseWriter, r *http.Request) {
77 | render.Render(w, r, NewRenderer)
78 | }
79 |
--------------------------------------------------------------------------------
/routes/picture.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/kataras/muxie"
7 | "github.com/lucat1/o2/pkg/images"
8 | "github.com/lucat1/o2/pkg/log"
9 | "github.com/lucat1/o2/pkg/render"
10 | "github.com/lucat1/o2/routes/shared"
11 | )
12 |
13 | // Picture returns a profile picture for the user(identified by a hash)
14 | func Picture(w http.ResponseWriter, r *http.Request) {
15 | hash := muxie.GetParam(w, "hash")
16 | data, err := images.Get(hash)
17 | if err != nil {
18 | log.
19 | Debug().
20 | Err(err).
21 | Str("hash", hash).
22 | Msg("Could not get profile picture")
23 |
24 | render.Render(w, r, shared.NotFoundRenderer)
25 | return
26 | }
27 |
28 | w.Header().Add("Content-Type", "image/jpeg")
29 | w.Write([]byte(data))
30 | }
31 |
--------------------------------------------------------------------------------
/routes/profile.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/kataras/muxie"
7 | "github.com/lucat1/o2/pkg/auth"
8 | "github.com/lucat1/o2/pkg/data"
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/lucat1/o2/pkg/models"
11 | "github.com/lucat1/o2/pkg/pex"
12 | "github.com/lucat1/o2/pkg/render"
13 | "github.com/lucat1/o2/routes/shared"
14 | uuid "github.com/satori/go.uuid"
15 | )
16 |
17 | // filter repositories visible to the given viewer
18 | func filterRepositories(owner uuid.UUID, viewer uuid.UUID) (res []models.Repository, err error) {
19 | repositories, err := models.SelectRepositories(owner)
20 | if err != nil {
21 | log.Debug().
22 | Err(err).
23 | Str("owner", owner.String()).
24 | Msg("Could not fetch profile repositories")
25 |
26 | return res, err
27 | }
28 |
29 | for _, repo := range repositories {
30 | if pex.Can(
31 | repo.UUID,
32 | viewer,
33 | []string{"repo:pull"},
34 | ) {
35 | res = append(res, repo)
36 | }
37 | }
38 |
39 | return
40 | }
41 |
42 | // ProfileRenderer returns the page and the render data for the profile
43 | var ProfileRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
44 | name := muxie.GetParam(w, "name")
45 | user, err := models.GetUser("name", name)
46 |
47 | if err != nil {
48 | log.Debug().
49 | Err(err).
50 | Str("name", name).
51 | Msg("Could not find user to render profile page")
52 |
53 | return shared.NotFoundRenderer(w, r)
54 | }
55 |
56 | // get the logged in user's ID
57 | account := uuid.Nil
58 | if auth.IsAuthenticated(r) {
59 | claims := r.Context().Value(auth.ClaimsKey).(*auth.Claims)
60 | account = claims.UUID
61 | }
62 |
63 | repos, err := filterRepositories(user.UUID, account)
64 | if err != nil {
65 | log.Debug().
66 | Err(err).
67 | Str("owner", user.UUID.String()).
68 | Str("viewer", account.String()).
69 | Msg("Could not filter user to repositories")
70 |
71 | return shared.NotFoundRenderer(w, r)
72 | }
73 |
74 | typ, key := "user", "organizations"
75 | if user.Type == models.OrganizationType {
76 | typ, key = "organization", "users"
77 | }
78 |
79 | value, err := models.SelectMapping(typ, user.UUID)
80 | if err != nil {
81 | log.Debug().
82 | Err(err).
83 | Str("user-type", string(user.Type)).
84 | Str("typ", typ).
85 | Str("key", key).
86 | Str("uuid", user.UUID.String()).
87 | Msg("Error while looking for user<->orgs mapping")
88 |
89 | return shared.NotFoundRenderer(w, r)
90 | }
91 |
92 | return render.Result{
93 | Page: string(user.Type),
94 | Tags: []string{
95 | render.OGPTag("type", "profile"),
96 | render.OGPTag("title", user.Name),
97 | render.TwitterTag("title", user.Name),
98 | render.OGPTag("image", "/picture/"+user.Picture),
99 | render.ProfileTag("username", user.Name),
100 | render.ProfileTag("first_name", user.Firstname),
101 | render.ProfileTag("last_name", user.Lastname),
102 | },
103 | Composers: []data.Composer{
104 | data.WithAny("profile", user),
105 | data.WithAny("repositories", repos),
106 | // either organizations -> []orgs, or users -> []users
107 | data.WithAny(key, value),
108 | },
109 | }
110 | }
111 |
112 | // Profile renders an user/organization profile
113 | func Profile(w http.ResponseWriter, r *http.Request) {
114 | render.Render(w, r, ProfileRenderer)
115 | }
116 |
--------------------------------------------------------------------------------
/routes/register.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/auth"
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/models"
9 | "github.com/lucat1/o2/pkg/render"
10 | )
11 |
12 | // RegisterRenderer returns the page and the render data for register
13 | var RegisterRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
14 | // ignore already logged-in users
15 | if auth.IsAuthenticated(r) {
16 | render.WithRedirect(FeedRenderer(w, r), "/")
17 | }
18 |
19 | desc := "Register on the o2 platform to" +
20 | " work on code together with your team using a fast and" +
21 | "seamless Git web interface"
22 | if r.Method != http.MethodPost {
23 | return render.Result{
24 | Page: "register",
25 | Tags: []string{
26 | render.OGPTag("title", "Login"),
27 | render.TwitterTag("title", "Login"),
28 | render.OGPTag("description", desc),
29 | render.TwitterTag("description", desc),
30 | },
31 | }
32 | }
33 |
34 | r.ParseMultipartForm(1 * 1024 * 1024 /* 1mb */)
35 | name := r.Form.Get("name")
36 | email := r.Form.Get("email")
37 | password := r.Form.Get("password")
38 |
39 | if name == "" || email == "" || password == "" {
40 | return render.Result{
41 | Page: "register",
42 | Composers: []data.Composer{
43 | data.WithAny("error", "Please fill in all the required fields"),
44 | },
45 | }
46 | }
47 |
48 | user := models.User{
49 | Type: models.UserType,
50 | Email: email,
51 | Name: name,
52 | }
53 | token, err := auth.Register(&user, password)
54 | if err != nil {
55 | return render.Result{
56 | Page: "register",
57 | Composers: []data.Composer{data.WithAny("error", err.Error())},
58 | }
59 | }
60 |
61 | auth.SetCookie(w, r, token)
62 | return render.WithRedirect(FeedRenderer(w, r), "/")
63 | }
64 |
65 | // Register renders the register page and handles authentication
66 | func Register(w http.ResponseWriter, r *http.Request) {
67 | render.Render(w, r, RegisterRenderer)
68 | }
69 |
--------------------------------------------------------------------------------
/routes/repository/blob.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/kataras/muxie"
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/git"
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/lucat1/o2/pkg/middleware"
11 | "github.com/lucat1/o2/pkg/models"
12 | "github.com/lucat1/o2/pkg/render"
13 | "github.com/lucat1/o2/routes/datas"
14 | "github.com/lucat1/o2/routes/shared"
15 | )
16 |
17 | // BlobRenderer returns the page and the render data for a blob page
18 | var BlobRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
19 | dbRepo := r.Context().Value(middleware.DbRepo).(models.Repository)
20 | repo := r.Context().Value(middleware.GitRepo).(*git.Repository)
21 | branch := muxie.GetParam(w, "branch")
22 | path := muxie.GetParam(w, "path")
23 |
24 | blob := repo.Branch(branch).Blob(path)
25 | d, err := blob.Read()
26 | if err != nil {
27 | log.Debug().
28 | Str("uuid", dbRepo.OwnerUUID.String()).
29 | Str("reponame", dbRepo.Name).
30 | Str("path", path).
31 | Err(err).
32 | Msg("Error while reading git blob from the filesystem repository")
33 |
34 | return shared.NotFoundRenderer(w, r)
35 | }
36 |
37 | return render.Result{
38 | Page: "repository/blob",
39 | Composers: []data.Composer{
40 | datas.RepositoryData(dbRepo),
41 | datas.BlobData(blob, d),
42 | },
43 | }
44 | }
45 |
46 | // Blob renders a file inside a repository
47 | func Blob(w http.ResponseWriter, r *http.Request) {
48 | render.Render(w, r, BlobRenderer)
49 | }
50 |
--------------------------------------------------------------------------------
/routes/repository/commit.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/kataras/muxie"
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/git"
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/lucat1/o2/pkg/middleware"
11 | "github.com/lucat1/o2/pkg/models"
12 | "github.com/lucat1/o2/pkg/render"
13 | "github.com/lucat1/o2/routes/datas"
14 | "github.com/lucat1/o2/routes/shared"
15 | )
16 |
17 | // CommitRenderer returns the page and the render data for the commit page
18 | var CommitRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
19 | dbRepo := r.Context().Value(middleware.DbRepo).(models.Repository)
20 | repo := r.Context().Value(middleware.GitRepo).(*git.Repository)
21 | sha := muxie.GetParam(w, "sha")
22 |
23 | commit, err := repo.Commit(sha)
24 | if err != nil {
25 | log.Debug().
26 | Str("uuid", dbRepo.OwnerUUID.String()).
27 | Str("reponame", dbRepo.Name).
28 | Str("sha", sha).
29 | Err(err).
30 | Msg("Error while looking for commit inside a git repository")
31 |
32 | return shared.NotFoundRenderer(w, r)
33 | }
34 |
35 | return render.Result{
36 | Page: "repository/commit",
37 | Composers: []data.Composer{
38 | datas.RepositoryData(dbRepo),
39 | data.WithAny("commit", commit),
40 | },
41 | }
42 | }
43 |
44 | // Commit renders the diff of a single commit
45 | func Commit(w http.ResponseWriter, r *http.Request) {
46 | render.Render(w, r, CommitRenderer)
47 | }
48 |
--------------------------------------------------------------------------------
/routes/repository/commits.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/kataras/muxie"
8 | "github.com/lucat1/o2/pkg/data"
9 | "github.com/lucat1/o2/pkg/git"
10 | "github.com/lucat1/o2/pkg/log"
11 | "github.com/lucat1/o2/pkg/middleware"
12 | "github.com/lucat1/o2/pkg/models"
13 | "github.com/lucat1/o2/pkg/render"
14 | "github.com/lucat1/o2/routes/datas"
15 | "github.com/lucat1/o2/routes/shared"
16 | )
17 |
18 | // CommitsRenderer returns the page and the render data for the commits page
19 | var CommitsRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
20 | dbRepo := r.Context().Value(middleware.DbRepo).(models.Repository)
21 | repo := r.Context().Value(middleware.GitRepo).(*git.Repository)
22 | branch := muxie.GetParam(w, "branch")
23 | _page := muxie.GetParam(w, "page")
24 |
25 | page := 0
26 | if _page != "" {
27 | id, err := strconv.Atoi(_page)
28 | if err != nil {
29 | log.Debug().
30 | Str("uuid", dbRepo.OwnerUUID.String()).
31 | Str("reponame", dbRepo.Name).
32 | Str("page", _page).
33 | Err(err).
34 | Msg("Invalid commits page")
35 |
36 | return shared.NotFoundRenderer(w, r)
37 | }
38 |
39 | page = id
40 | }
41 |
42 | commits, err := repo.Branch(branch).Commits(page, 20)
43 | if err != nil {
44 | log.Debug().
45 | Str("uuid", dbRepo.OwnerUUID.String()).
46 | Str("reponame", dbRepo.Name).
47 | Int("page", page).
48 | Err(err).
49 | Msg("Error while getting git commits from the filesystem repository")
50 |
51 | return shared.NotFoundRenderer(w, r)
52 | }
53 |
54 | return render.Result{
55 | Page: "repository/commits",
56 | Composers: []data.Composer{
57 | datas.RepositoryData(dbRepo),
58 | data.WithAny("branch", branch),
59 | data.WithAny("commits", commits.Commits),
60 | data.WithAny("index", commits.Index),
61 | data.WithAny("prev", commits.Prev),
62 | data.WithAny("next", commits.Next),
63 | },
64 | }
65 | }
66 |
67 | // Commits lists the latest 20 commits of a repository
68 | func Commits(w http.ResponseWriter, r *http.Request) {
69 | render.Render(w, r, CommitsRenderer)
70 | }
71 |
--------------------------------------------------------------------------------
/routes/repository/issue.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/kataras/muxie"
8 | "github.com/lucat1/o2/pkg/auth"
9 | "github.com/lucat1/o2/pkg/data"
10 | "github.com/lucat1/o2/pkg/log"
11 | "github.com/lucat1/o2/pkg/middleware"
12 | "github.com/lucat1/o2/pkg/models"
13 | "github.com/lucat1/o2/pkg/render"
14 | "github.com/lucat1/o2/routes/datas"
15 | "github.com/lucat1/o2/routes/shared"
16 | )
17 |
18 | func IssueRenderer(w http.ResponseWriter, r *http.Request) render.Result {
19 | repo := r.Context().Value(middleware.DbRepo).(models.Repository)
20 |
21 | rawID := muxie.GetParam(w, "id")
22 | id, err := strconv.Atoi(rawID)
23 | issue, err := models.GetIssue(repo.UUID, id)
24 | if err != nil {
25 | log.Error().
26 | Err(err).
27 | Str("repository", repo.UUID.String()).
28 | Str("raw_id", rawID).
29 | Int("id", id).
30 | Msg("Could not find issue")
31 |
32 | return shared.NotFoundRenderer(w, r)
33 | }
34 |
35 | comments, err := models.SelectIssueComments(int(issue.ID))
36 | if err != nil {
37 | log.Error().
38 | Err(err).
39 | Int("issue", id).
40 | Msg("Could not get issue comments")
41 |
42 | return shared.NotFoundRenderer(w, r)
43 | }
44 |
45 | if r.Method != http.MethodPost {
46 | return render.Result{
47 | Page: "repository/issue",
48 | Composers: []data.Composer{
49 | datas.RepositoryData(repo),
50 | data.WithAny("issue", issue),
51 | data.WithAny("comments", comments),
52 | },
53 | }
54 | }
55 |
56 | // ignore unauthenticated POSTs as they cannot comment under issues
57 | if !auth.IsAuthenticated(r) {
58 | return shared.NotFoundRenderer(w, r)
59 | }
60 |
61 | // handle comments creation
62 | author := r.Context().Value(auth.AccountKey).(*models.User)
63 | r.ParseMultipartForm(1 * 1024 * 1024 /* 1mb */)
64 | body := r.Form.Get("body")
65 |
66 | comment := models.IssueComment{
67 | Issue: issue.ID,
68 | Author: author.UUID,
69 | Picture: author.Picture,
70 | Body: body,
71 | }
72 | if err := comment.Insert(); err != nil {
73 | log.Error().
74 | Err(err).
75 | Int64("issue", issue.ID).
76 | Str("author", author.UUID.String()).
77 | Str("body", body).
78 | Msg("Could not add a comment to the issue")
79 |
80 | return shared.NotFoundRenderer(w, r)
81 | }
82 |
83 | return render.Result{
84 | Page: "repository/issue",
85 | Composers: []data.Composer{
86 | datas.RepositoryData(repo),
87 | data.WithAny("issue", issue),
88 | data.WithAny("comments", append(comments, comment)),
89 | },
90 | }
91 | }
92 |
93 | func Issue(w http.ResponseWriter, r *http.Request) {
94 | render.Render(w, r, IssueRenderer)
95 | }
96 |
--------------------------------------------------------------------------------
/routes/repository/issues.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/data"
7 | "github.com/lucat1/o2/pkg/log"
8 | "github.com/lucat1/o2/pkg/middleware"
9 | "github.com/lucat1/o2/pkg/models"
10 | "github.com/lucat1/o2/pkg/render"
11 | "github.com/lucat1/o2/routes/datas"
12 | "github.com/lucat1/o2/routes/shared"
13 | )
14 |
15 | func IssuesRenderer(w http.ResponseWriter, r *http.Request) render.Result {
16 | repo := r.Context().Value(middleware.DbRepo).(models.Repository)
17 |
18 | issues, err := models.SelectIssues(repo.UUID)
19 | if err != nil {
20 | log.Error().
21 | Err(err).
22 | Str("repository", repo.UUID.String()).
23 | Msg("Could not find repository issues")
24 |
25 | return shared.NotFoundRenderer(w, r)
26 | }
27 |
28 | return render.Result{
29 | Page: "repository/issues",
30 | Composers: []data.Composer{
31 | datas.RepositoryData(repo),
32 | data.WithAny("issues", issues),
33 | },
34 | }
35 | }
36 |
37 | func Issues(w http.ResponseWriter, r *http.Request) {
38 | render.Render(w, r, IssuesRenderer)
39 | }
40 |
--------------------------------------------------------------------------------
/routes/repository/new.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/kataras/muxie"
8 | "github.com/lucat1/o2/pkg/auth"
9 | "github.com/lucat1/o2/pkg/data"
10 | "github.com/lucat1/o2/pkg/log"
11 | "github.com/lucat1/o2/pkg/middleware"
12 | "github.com/lucat1/o2/pkg/models"
13 | "github.com/lucat1/o2/pkg/render"
14 | "github.com/lucat1/o2/routes/datas"
15 | "github.com/lucat1/o2/routes/shared"
16 | )
17 |
18 | func NewIssueRenderer(w http.ResponseWriter, r *http.Request) render.Result {
19 | repo := r.Context().Value(middleware.DbRepo).(models.Repository)
20 |
21 | if r.Method != http.MethodPost {
22 | return render.Result{
23 | Page: "repository/new",
24 | Composers: []data.Composer{
25 | datas.RepositoryData(repo),
26 | },
27 | }
28 | }
29 |
30 | // gather the new issue ID
31 | id, err := models.GetIssueID(repo.UUID)
32 | if err != nil {
33 | log.Error().
34 | Err(err).
35 | Str("repository", repo.UUID.String()).
36 | Msg("Could not get the latest issue id")
37 |
38 | return shared.NotFoundRenderer(w, r)
39 | }
40 |
41 | // increase the new issue ID
42 | id++
43 |
44 | // the logged in user
45 | author := r.Context().Value(auth.AccountKey).(*models.User)
46 |
47 | // create a new issue with the given data
48 | r.ParseMultipartForm(1 * 1024 * 1024 /* 1mb */)
49 | title := r.Form.Get("title")
50 | body := r.Form.Get("body")
51 |
52 | issue := models.Issue{
53 | Repository: repo.UUID,
54 | RelativeID: id,
55 | Author: author.UUID,
56 | Title: title,
57 | }
58 | if err := issue.Insert(); err != nil {
59 | log.Error().
60 | Err(err).
61 | Str("title", title).
62 | Str("repository", repo.UUID.String()).
63 | Str("author", author.UUID.String()).
64 | Msg("Could not add a new issue to the database")
65 |
66 | return shared.NotFoundRenderer(w, r)
67 | }
68 |
69 | comment := models.IssueComment{
70 | Issue: issue.ID,
71 | Author: author.UUID,
72 | Body: body,
73 | }
74 | if err := comment.Insert(); err != nil {
75 | log.Error().
76 | Err(err).
77 | Int64("issue", issue.ID).
78 | Str("author", author.UUID.String()).
79 | Str("body", body).
80 | Msg("Could not create the first issue comment")
81 |
82 | return shared.NotFoundRenderer(w, r)
83 | }
84 |
85 | // fake a GET request to the new issue to render properly
86 | muxie.SetParam(w, "id", strconv.Itoa(int(issue.RelativeID)))
87 | r.Method = http.MethodGet
88 |
89 | return render.WithRedirect(
90 | IssueRenderer(w, r),
91 | "/"+repo.OwnerName+"/"+repo.Name+"/issue/"+strconv.Itoa(int(id)),
92 | )
93 | }
94 |
95 | func NewIssue(w http.ResponseWriter, r *http.Request) {
96 | render.Render(w, r, NewIssueRenderer)
97 | }
98 |
--------------------------------------------------------------------------------
/routes/repository/repository.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 |
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/git"
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/lucat1/o2/pkg/middleware"
11 | "github.com/lucat1/o2/pkg/models"
12 | "github.com/lucat1/o2/pkg/render"
13 | "github.com/lucat1/o2/routes/datas"
14 | )
15 |
16 | // RepositoryRenderer returns the page and the render data for a repository view
17 | var RepositoryRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
18 | dbRepo := r.Context().Value(middleware.DbRepo).(models.Repository)
19 | repo := r.Context().Value(middleware.GitRepo).(*git.Repository)
20 |
21 | // ignore the error. If we get and error it means the repository has not commits
22 | // but that's fine as the client will display the `how to push first commit` message
23 | tree, _ := repo.Branch("master").Tree(".")
24 |
25 | // find a readme and read it if available
26 | readme, valid := "", []string{"readme.md", "readme"}
27 | if tree != nil {
28 | for _, child := range tree.Children {
29 | stop := false
30 | if stop {
31 | break
32 | }
33 |
34 | if blob, ok := child.(git.Blob); ok {
35 | for _, name := range valid {
36 | if strings.ToLower(blob.Name) == name {
37 | readme = blob.Name
38 | stop = true
39 | break
40 | }
41 | }
42 | }
43 | }
44 | }
45 |
46 | readmeContent := ""
47 | if readme != "" {
48 | blob := repo.Branch("master").Blob(readme)
49 | data, err := blob.Read()
50 | if err != nil {
51 | log.Debug().
52 | Err(err).
53 | Str("readme", readme).
54 | Msg("Error while reading README blobk")
55 | }
56 | readmeContent = data
57 | }
58 |
59 | // also provide all refs(branches/tags)
60 | refs, _ := repo.Refs()
61 |
62 | title := dbRepo.OwnerName + "/" + dbRepo.Name
63 | return render.Result{
64 | Page: "repository/repository",
65 | Tags: []string{
66 | render.OGPTag("image", ""), // TODO: repository images!!
67 | render.OGPTag("type", "object"),
68 |
69 | render.OGPTag("title", title),
70 | render.TwitterTag("title", title),
71 |
72 | render.OGPTag("description", dbRepo.Description),
73 | render.TwitterTag("description", dbRepo.Description),
74 | },
75 | Composers: []data.Composer{
76 | datas.RepositoryData(dbRepo),
77 | data.WithAny("tree", tree),
78 | data.WithAny("readme", readmeContent),
79 | data.WithAny("refs", refs),
80 | },
81 | }
82 | }
83 |
84 | // Repository renders the view of a git repository
85 | func Repository(w http.ResponseWriter, r *http.Request) {
86 | render.Render(w, r, RepositoryRenderer)
87 | }
88 |
--------------------------------------------------------------------------------
/routes/repository/settings.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/data"
7 | "github.com/lucat1/o2/pkg/middleware"
8 | "github.com/lucat1/o2/pkg/models"
9 | "github.com/lucat1/o2/routes/datas"
10 | "github.com/lucat1/quercia"
11 | )
12 |
13 | // Settings renders the settings of a repository
14 | func Settings(w http.ResponseWriter, r *http.Request) {
15 | dbRepo := r.Context().Value(middleware.DbRepo).(models.Repository)
16 |
17 | quercia.Render(
18 | w, r,
19 | "repository/settings",
20 | data.Compose(r, data.Base, datas.RepositoryData(dbRepo)),
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/routes/repository/tree.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/kataras/muxie"
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/git"
9 | "github.com/lucat1/o2/pkg/log"
10 | "github.com/lucat1/o2/pkg/middleware"
11 | "github.com/lucat1/o2/pkg/models"
12 | "github.com/lucat1/o2/pkg/render"
13 | "github.com/lucat1/o2/routes/datas"
14 | "github.com/lucat1/o2/routes/shared"
15 | )
16 |
17 | // TreeRenderer returns the page and the render data for a tree view
18 | var TreeRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
19 | branch := muxie.GetParam(w, "branch")
20 | path := muxie.GetParam(w, "path")
21 | dbRepo := r.Context().Value(middleware.DbRepo).(models.Repository)
22 | repo := r.Context().Value(middleware.GitRepo).(*git.Repository)
23 |
24 | if path == "" {
25 | path = "."
26 | }
27 | tree, err := repo.Branch(branch).Tree(path)
28 | if err != nil {
29 | log.Debug().
30 | Str("uuid", dbRepo.OwnerUUID.String()).
31 | Str("reponame", dbRepo.Name).
32 | Str("path", path).
33 | Err(err).
34 | Msg("Error while getting git tree from the filesystem repository")
35 | return shared.NotFoundRenderer(w, r)
36 | }
37 |
38 | return render.Result{
39 | Page: "repository/tree",
40 | Composers: []data.Composer{
41 | datas.RepositoryData(dbRepo),
42 | data.WithAny("tree", tree),
43 | },
44 | }
45 | }
46 |
47 | // Tree renders a folder inside a repository
48 | func Tree(w http.ResponseWriter, r *http.Request) {
49 | render.Render(w, r, TreeRenderer)
50 | }
51 |
--------------------------------------------------------------------------------
/routes/settings/privacy.go:
--------------------------------------------------------------------------------
1 | package settings
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/auth"
7 | "github.com/lucat1/o2/pkg/data"
8 | "github.com/lucat1/o2/pkg/log"
9 | "github.com/lucat1/o2/pkg/models"
10 | "github.com/lucat1/o2/pkg/render"
11 | "github.com/lucat1/o2/routes"
12 | "golang.org/x/crypto/bcrypt"
13 | )
14 |
15 | // PrivacyRenderer returns the page and the render data for the privacy settings view
16 | var PrivacyRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
17 | user := r.Context().Value(auth.AccountKey).(*models.User)
18 | if user == nil {
19 | return render.WithRedirect(routes.LoginRenderer(w, r), "/login?to="+r.URL.Path)
20 | }
21 |
22 | // render the ui if the request is not a post
23 | if r.Method != http.MethodPost {
24 | return render.Result{
25 | Page: "settings/privacy",
26 | Composers: []data.Composer{
27 | data.WithAny("profile", user),
28 | },
29 | }
30 | }
31 |
32 | r.ParseMultipartForm(1 * 1024 * 1024 /* 1mb */)
33 | // Check if we are updating the user's password
34 | currentPassword := r.Form.Get("current")
35 | newPassword := r.Form.Get("new")
36 |
37 | var err string
38 | if currentPassword != "" && newPassword != "" {
39 | // check if the current passowrd matches
40 | if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(currentPassword)) != nil {
41 | err = "The current password is invalid."
42 | goto renderError
43 | }
44 |
45 | // update the password and save it into the database
46 | hashed, e := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
47 | if e != nil {
48 | log.Error().Err(e).Msg("Could not hash new password during a password change")
49 |
50 | err = "Internal error. Please try again later"
51 | goto renderError
52 | }
53 |
54 | user.Password = string(hashed)
55 | if e := user.Update(); e != nil {
56 | log.Error().Err(e).Bytes("hashed", hashed).Msg("Error while updating a user's password")
57 |
58 | err = "Internal error. Please try again later"
59 | goto renderError
60 | }
61 |
62 | // Hard redirect to refresh the data
63 | return render.Result{
64 | Redirect: "/" + user.Name,
65 | }
66 | }
67 |
68 | goto renderError
69 |
70 | renderError:
71 | return render.Result{
72 | Page: "settings/privacy",
73 | Composers: []data.Composer{
74 | data.WithAny("error", err),
75 | },
76 | }
77 | }
78 |
79 | // Privacy is a settings tab used to change privacy-concerned settings
80 | // like passwords, emails and such
81 | func Privacy(w http.ResponseWriter, r *http.Request) {
82 | render.Render(w, r, PrivacyRenderer)
83 | }
84 |
--------------------------------------------------------------------------------
/routes/shared/404.go:
--------------------------------------------------------------------------------
1 | package shared
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/lucat1/o2/pkg/data"
7 | "github.com/lucat1/o2/pkg/render"
8 | )
9 |
10 | // NotFoundRenderer returns the page and data to render a 404 url
11 | var NotFoundRenderer render.Renderer = func(w http.ResponseWriter, r *http.Request) render.Result {
12 | w.WriteHeader(http.StatusNotFound)
13 |
14 | desc := "Work on code together with your team using a fast and seamless Git web interface"
15 | return render.Result{
16 | Page: "404",
17 | Tags: []string{
18 | render.OGPTag("title", "Not found"),
19 | render.TwitterTag("title", "Not found"),
20 | render.OGPTag("description", desc),
21 | render.TwitterTag("description", desc),
22 | },
23 | Composers: []data.Composer{data.WithAny("path", r.URL.Path)},
24 | }
25 | }
26 |
27 | // NotFound renders a 404 page
28 | func NotFound(w http.ResponseWriter, r *http.Request) {
29 | render.Render(w, r, NotFoundRenderer)
30 | }
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES6",
4 | "module": "ESNext",
5 | "moduleResolution": "node",
6 | "jsx": "react",
7 | "typeRoots": ["./auth/node_modules/@types"],
8 | "types": ["node"]
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/types/data.ts:
--------------------------------------------------------------------------------
1 | export interface User extends LoggedUser {
2 | firstname: string
3 | lastname: string
4 | description: string
5 | location: string
6 | picture: string
7 | }
8 |
9 | export interface Organization {
10 | name: string
11 | description: string
12 | location: string
13 | picture: string
14 | }
15 |
16 | export interface Commit {
17 | commit: string
18 | abbrv: string
19 | tree: string
20 | abbrv_tree: string
21 | subject: string
22 | body: string
23 | author: Author
24 | commiter: Author
25 | }
26 |
27 | export interface DetailedCommit extends Commit {
28 | diff: string
29 | }
30 |
31 | export interface Author {
32 | name: string
33 | email: string
34 | picture: string
35 | date: string
36 | }
37 |
38 | export interface Event {
39 | type: T
40 | time: string
41 | data: any
42 |
43 | // repository data
44 | owner: string
45 | name: string
46 | }
47 |
48 | export interface CommitEvent extends Event<'commit'> {
49 | data: CommitEventData
50 | }
51 |
52 | export type CreateRepositoryEvent = Event<'create-repository'>
53 |
54 | export interface CommitEventData {
55 | commits: Commit[]
56 | more: boolean
57 | }
58 |
59 | export interface LoggedUser {
60 | name: string
61 | email: string
62 | picture: string
63 | }
64 |
65 | export type Base = { account?: LoggedUser } & T
66 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.woff2' {
2 | const path: string
3 | export default path
4 | }
5 |
--------------------------------------------------------------------------------
/types/repository.ts:
--------------------------------------------------------------------------------
1 | import { Base as IBase, User } from './data'
2 |
3 | export interface Repository {
4 | owner: string
5 | name: string
6 | description: string
7 | }
8 |
9 | export interface BlobProps {
10 | blob: Blob
11 | data: string
12 | ext: string
13 | }
14 |
15 | export type Base = IBase<{ repository: Repository; owns: boolean } & T>
16 |
17 | export enum EntryKind {
18 | TREE = 0,
19 | BLOB = 1
20 | }
21 |
22 | export interface Entry {
23 | kind: EntryKind
24 | mode: string
25 | size: number
26 | branch: { name: string }
27 | }
28 |
29 | export interface Tree extends Entry {
30 | path: string
31 | children?: Entry[]
32 | }
33 |
34 | export interface Blob extends Entry {
35 | name: string
36 | }
37 |
38 | export interface Ref {
39 | kind: 'branch' | 'tag'
40 | name: string
41 | sha: string
42 | }
43 |
44 | export interface RepositoryProps {
45 | readme: string
46 | tree: Tree
47 | refs: Ref[]
48 | }
49 |
50 | export interface Issue {
51 | opened: string
52 | id: number
53 | title: string
54 |
55 | name: string
56 | }
57 |
58 | export interface Comment {
59 | commented: string
60 | edited: string
61 | body: string
62 |
63 | picture: string
64 | name: string
65 | }
66 |
--------------------------------------------------------------------------------
/types/theme.ts:
--------------------------------------------------------------------------------
1 | import { Theme } from 'styled-system'
2 |
3 | const fontFamily = "'Operator Mono', monospace, mono"
4 |
5 | export const base: Theme = {
6 | colors: {
7 | primary: {
8 | default: '#c792ea',
9 | light: 'rgba(var(--primary-rgb), 0.4);'
10 | },
11 | error: '#fd9726',
12 | bg: {
13 | 3: 'var(--bg-3)',
14 | 4: 'var(--bg-4)',
15 | 5: 'var(--bg-5)',
16 | 6: 'var(--bg-6)'
17 | },
18 | fg: {
19 | 5: 'var(--fg-5)'
20 | },
21 | red: 'var(--red)',
22 | green: 'var(--green)'
23 | },
24 | space: [
25 | 0,
26 | '.25rem',
27 | '.5rem',
28 | '.75rem',
29 | '1rem',
30 | '1.5rem',
31 | '2rem',
32 | '3rem',
33 | '4rem',
34 | '5rem',
35 | '6rem'
36 | ],
37 | sizes: {
38 | 0: 0,
39 | 1: '.5rem',
40 | 2: '1rem',
41 | 3: '1.5rem',
42 | 4: '2rem',
43 | 5: '2.5rem',
44 | 6: '3rem',
45 | 7: '5rem',
46 | 8: '10rem',
47 | 9: '15rem',
48 |
49 | md: '1.25rem'
50 | },
51 | radii: {
52 | sm: '.25rem',
53 | md: '.5rem',
54 | lg: '50%'
55 | },
56 | fontSizes: {
57 | xs: '.75em',
58 | sm: '.85em',
59 | md: 'calc(1rem + 0.25vw)',
60 | lg: '1.5em'
61 | },
62 | fonts: {
63 | default: fontFamily,
64 | heading: fontFamily
65 | },
66 | shadows: {
67 | focus: '0 0 0 4px rgba(var(--primary-rgb), 0.3)',
68 | sm: '0px 3px 5px rgba(0, 0, 0, 0.04)'
69 | },
70 | breakpoints: ['960px', '1440px']
71 | }
72 |
--------------------------------------------------------------------------------
/types/time.ts:
--------------------------------------------------------------------------------
1 | import format from 'tinydate'
2 |
3 | const Minute = 1000 /* s */ * 60 /* m */
4 | const Hour = Minute * 60 /* h */
5 | const Week = Hour * 24 /* d */ * 7 /* week */
6 |
7 | export default function elapsed(date: string | Date | undefined): string {
8 | if (typeof date === 'undefined') {
9 | return ''
10 | }
11 |
12 | if (typeof date === 'string') {
13 | date = new Date(date)
14 | }
15 |
16 | let diff = new Date().getTime() - date.getTime()
17 |
18 | // if more than a week has elapsed
19 | if (diff > Week) {
20 | return format('on the {DD}/{MM}/{YYYY}')(date)
21 | }
22 |
23 | // if less than a minute has elapsed
24 | if (diff < Minute) {
25 | const seconds = Math.floor(diff / 1000)
26 | return `${seconds} second${seconds > 1 ? 's' : ''} ago`
27 | }
28 |
29 | // if less than an hour has elapsed
30 | if (diff < Hour) {
31 | const minutes = Math.floor(diff / 60000)
32 | return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
33 | }
34 |
35 | diff /= 1000
36 | const hours = Math.floor(diff / 3600)
37 | const days = Math.floor(hours / 24)
38 |
39 | if (days > 0) {
40 | return `${days} day${days > 1 ? 's' : ''}, ${hours} hour${
41 | hours > 1 ? 's' : ''
42 | } ago`
43 | } else {
44 | return `${hours} hour${hours > 1 ? 's' : ''} ago`
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/types/xtend.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * assign-deep
3 | *
4 | * Copyright (c) 2017-present, Jon Schlinkert.
5 | * Released under the MIT License.
6 | */
7 |
8 | // tweaked to o2 needs
9 |
10 | const toString = Object.prototype.toString
11 |
12 | const isValidKey = key => {
13 | return key !== '__proto__' && key !== 'constructor' && key !== 'prototype'
14 | }
15 |
16 | const assign = (target, ...args) => {
17 | let i = 0
18 | if (isPrimitive(target)) target = args[i++]
19 | if (!target) target = {}
20 | for (; i < args.length; i++) {
21 | if (isObject(args[i])) {
22 | for (const key of Object.keys(args[i])) {
23 | if (isValidKey(key)) {
24 | if (isObject(target[key]) && isObject(args[i][key])) {
25 | assign(target[key], args[i][key])
26 | } else {
27 | target[key] = args[i][key]
28 | }
29 | }
30 | }
31 | Object.assign(target, args[i])
32 | }
33 | }
34 | return target
35 | }
36 |
37 | function isObject(val) {
38 | return typeof val === 'function' || toString.call(val) === '[object Object]'
39 | }
40 |
41 | function isPrimitive(val) {
42 | return typeof val === 'object' ? val === null : typeof val !== 'function'
43 | }
44 |
45 | export default assign
46 |
--------------------------------------------------------------------------------