├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── __init__.py ├── app.py ├── comments.py ├── media.py ├── progress.py ├── redarc_logger.py ├── search.py ├── status.py ├── submissions.py ├── submit.py ├── subreddits.py ├── unlist.py └── watch.py ├── default.env ├── docker-compose.yml ├── docs ├── redarc_architecture.png ├── screenshot.png └── screenshot2.png ├── frontend ├── .eslintrc.cjs ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── public │ └── bootstrap │ │ ├── css │ │ ├── bootstrap-responsive.css │ │ ├── bootstrap-responsive.min.css │ │ ├── bootstrap.css │ │ └── bootstrap.min.css │ │ ├── img │ │ ├── glyphicons-halflings-white.png │ │ └── glyphicons-halflings.png │ │ └── js │ │ ├── bootstrap.js │ │ └── bootstrap.min.js ├── sample.env ├── src │ ├── main.jsx │ └── routes │ │ ├── About.jsx │ │ ├── Comment.jsx │ │ ├── Error.jsx │ │ ├── Footer.jsx │ │ ├── Post.jsx │ │ ├── Progress.jsx │ │ ├── Results.jsx │ │ ├── Search.jsx │ │ ├── Status.jsx │ │ ├── Submit.jsx │ │ ├── Subreddit.jsx │ │ ├── Thread.jsx │ │ └── root.jsx └── vite.config.js ├── ingest ├── .dockerignore ├── README.md ├── image_downloader │ ├── .dockerignore │ ├── Dockerfile │ ├── __init__.py │ └── image_downloader.py ├── index_worker │ ├── .dockerignore │ ├── Dockerfile │ └── index_worker.py ├── reddit_worker │ ├── .dockerignore │ ├── Dockerfile │ ├── __init__.py │ ├── reddit_worker.py │ └── validate.py └── subreddit_worker │ ├── .dockerignore │ ├── Dockerfile │ └── subreddit_worker.py ├── nginx ├── nginx_envar.py └── redarc_original.conf └── scripts ├── backfill_images.py ├── db_comments.sql ├── db_comments_index.sql ├── db_date_retrieved.sql ├── db_fts.sql ├── db_progress.sql ├── db_status_comments.sql ├── db_status_submissions.sql ├── db_submissions.sql ├── db_submissions_index.sql ├── db_subreddits.sql ├── db_watchedsubreddits.sql ├── hn_load_item.py ├── index.py ├── load_comments.py ├── load_comments_fts.py ├── load_sub.py ├── load_sub_fts.py ├── start.sh └── unlist.py /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | postgres-docker 3 | */.env 4 | */redarc.conf 5 | */dist 6 | config.json 7 | *.log 8 | redarc-ingest* 9 | docs/ 10 | ingest/ 11 | LICENSE 12 | docker-compose.yml 13 | Dockerfile 14 | README.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config.json 3 | postgres-docker 4 | *.log 5 | *.pyc 6 | .env 7 | venv -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine3.17 2 | 3 | RUN apk update 4 | # Download the runtime dependencies 5 | RUN apk add --no-cache bash nginx python3 py3-pip postgresql-client 6 | 7 | RUN mkdir -p /redarc 8 | WORKDIR /redarc 9 | COPY . . 10 | 11 | RUN pip install gunicorn 12 | RUN pip install falcon 13 | RUN pip install rq 14 | RUN pip install python-dotenv 15 | RUN pip install psycopg2-binary 16 | 17 | WORKDIR /redarc/frontend 18 | RUN npm ci 19 | 20 | WORKDIR /redarc 21 | RUN chmod +x scripts/start.sh 22 | CMD ["/bin/bash", "scripts/start.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yakabuff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redarc 2 | A self-hosted solution to search, view and archive link aggregators. 3 | 4 | ### Supports: 5 | - Reddit 6 | - HackerNews (in progress) 7 | 8 | ## Features: 9 | - Ingest pushshift dumps 10 | - View threads/comments 11 | - Fulltext search via PostgresFTS 12 | - Submit threads to be archived via API 13 | - Periodically fetch rising, new and hot threads from specified subreddits 14 | - Download `i.redd.it` images from threads. 15 | 16 | Please abide by the Reddit Terms of Service and [User Agreement](https://www.redditinc.com/policies/user-agreement-april-18-2023) if you are using their API 17 | 18 | ![Alt text](docs/screenshot.png "screenshot") 19 | ![Alt text](docs/screenshot2.png "screenshot2") 20 | 21 | ### Download pushshift dumps 22 | 23 | ``` 24 | https://the-eye.eu/redarcs/ 25 | ``` 26 | All data 2005-06 to 2022-12: 27 | ``` 28 | magnet:?xt=urn:btih:7c0645c94321311bb05bd879ddee4d0eba08aaee&tr=https%3A%2F%2Facademictorrents.com%2Fannounce.php&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce 29 | ``` 30 | Top 20,000 subreddits: 31 | ``` 32 | magnet:?xt=urn:btih:c398a571976c78d346c325bd75c47b82edf6124e&tr=https%3A%2F%2Facademictorrents.com%2Fannounce.php&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce 33 | ``` 34 | # Installation: 35 | 36 | Master branch is unstable. Please checkout a release 37 | 38 | ## Docker 39 | 40 | Install Docker: https://docs.docker.com/engine/install 41 | 42 | Services: 43 | - `postgres`: Main database for threads, comments and subreddits 44 | - `postgres_fts`: Database for full-text searching 45 | - `redarc`: API backend and React frontend 46 | - Requires: `redis`, `reddit_worker` if `INGEST_ENABLED` 47 | - `redis`: Required for any service that uses a task queue 48 | - `image_downloader`: Asynchronously downloads images from Reddit if `DOWNLOAD_IMAGES` 49 | - Requires: `redis`, `reddit_worker` 50 | - `index_worker`: Indexes threads/comments into postgres_fts 51 | - Requires: `postgres_fts` and `postgres` 52 | - `reddit_worker`: Asynchronously fetches threads/comments from Reddit 53 | - Requires: `redis`, `image_downloader` 54 | - `subreddit_worker`: Asynchronously fetches hot/new/rising thread IDs from subreddits 55 | - Requires: `reddit_worker` and `redis` 56 | 57 | If you wish to change the postgres password, make sure `POSTGRES_PASSWORD` and `PGPASSWORD` are the same. 58 | 59 | If you are using redarc on your personal machine, set docker envars `REDARC_API=http://localhost/api` and `SERVER_NAME=localhost`. 60 | 61 | `REDARC_API` is the URL of your API server; it must end with `/api` 62 | eg: `http://redarc.mysite.org/api`. 63 | 64 | `REDARC_FE_API` is the URL of the API server you want the frontend to send requests to. 65 | If you are not using a reverse proxy, it should be the same as `REDARC_API`. 66 | 67 | `SERVER_NAME` is the URL your redarc instance is running on. eg: `redarc.mysite.org` 68 | 69 | Setting an `INGEST_PASSWORD` and `ADMIN_PASSWORD` in your API is highly recommended to prevent abuse. 70 | 71 | `IMAGE_PATH` is the path you want `image_downloader` worker to download images. This is the same path the API backend fetches images from. 72 | 73 | `INDEX_DELAY` is how often you want `index_worker` to index comments/threads 74 | 75 | `SUBREDDITS` is a list of subreddits you want `subreddit_worker` to fetch threads from. It is delimited by commas 76 | 77 | `FETCH_DELAY` is how often you `subreddit_worker` to fetch threads. 78 | 79 | `NUM_THREADS` is the number of threads you want downloaded from hot, rising or new. 80 | 81 | ## Docker compose (Recommended): 82 | 83 | Docker compose: 84 | 85 | Modify envars as needed 86 | ``` 87 | $ git clone https://github.com/Yakabuff/redarc.git 88 | $ cd redarc 89 | $ git fetch --all --tags 90 | $ git checkout tags/vx.y.z -b vx.y.z 91 | // Modify .env as-needed 92 | $ cp default.env .env 93 | $ docker compose up -d 94 | ``` 95 | 96 | ## Manual installation: 97 | 98 | ``` 99 | $ git clone https://github.com/Yakabuff/redarc.git 100 | $ cd redarc 101 | ``` 102 | ### 1) Provision Postgres database 103 | 104 | ``` 105 | $ docker pull postgres 106 | $ docker run \ 107 | --name pgsql-dev \ 108 | -e POSTGRES_PASSWORD=test1234 \ 109 | -d \ 110 | -v postgres-docker:/var/lib/postgresql/data \ 111 | -p 5432:5432 postgres 112 | ``` 113 | 114 | ``` 115 | $ docker run \ 116 | --name pgsql-fts \ 117 | -e POSTGRES_PASSWORD=test1234 \ 118 | -d \ 119 | -v postgresfts-docker:/var/lib/postgresql/data \ 120 | -p 5433:5432 postgres 121 | ``` 122 | 123 | ``` 124 | psql -h localhost -U postgres -a -f scripts/db_submissions.sql 125 | psql -h localhost -U postgres -a -f scripts/db_comments.sql 126 | psql -h localhost -U postgres -a -f scripts/db_subreddits.sql 127 | psql -h localhost -U postgres -a -f scripts/db_submissions_index.sql 128 | psql -h localhost -U postgres -a -f scripts/db_comments_index.sql 129 | psql -h localhost -U postgres -a -f scripts/db_status_comments.sql 130 | psql -h localhost -U postgres -a -f scripts/db_status_comments_index.sql 131 | psql -h localhost -U postgres -a -f scripts/db_status_submissions.sql 132 | psql -h localhost -U postgres -a -f scripts/db_status_submissions_index.sql 133 | psql -h localhost -U postgres -p 5433 -a -f scripts/db_fts.sql 134 | psql -h localhost -U postgres -a -f scripts/db_progress.sql 135 | ``` 136 | 137 | ### 2) Process dump and insert rows into postgres database with the load_sub/load_comments scripts 138 | 139 | Note: Be sure the ingest and Reddit workers are disabled 140 | ``` 141 | python3 scripts/load_sub.py 142 | python3 scripts/load_comments.py 143 | python3 scripts/load_sub_fts.py 144 | python3 scripts/load_comments_fts.py 145 | python3 scripts/index.py [subreddit_name] 146 | python3 scripts/unlist.py 147 | ``` 148 | 149 | ### 3) Start the API server. 150 | 151 | ``` 152 | $ cd api 153 | $ python -m venv venv 154 | $ source venv/bin/activate 155 | $ pip install gunicorn 156 | $ pip install falcon 157 | $ pip install rq 158 | $ pip install python-dotenv 159 | $ pip install psycopg2-binary 160 | $ gunicorn app 161 | ``` 162 | 163 | ### 4) Start the frontend 164 | 165 | ``` 166 | cd ../redarc-frontend 167 | mv sample.env .env 168 | ``` 169 | Set address for API server in the .env file 170 | 171 | ``` 172 | VITE_API_DOMAIN=http://my-api-server.com/api/ 173 | ``` 174 | 175 | ``` 176 | npm i 177 | npm run dev // Dev server 178 | ``` 179 | 180 | ### 5) Provision NGINX (Optional) 181 | 182 | Edit nginx/nginx_original.conf with your own values 183 | ``` 184 | $ cd .. 185 | $ mv nginx/redarc_original.conf /etc/nginx/conf.d/redarc.conf 186 | ``` 187 | 188 | ``` 189 | cd redarc-frontend 190 | npm run build 191 | cp -R dist/* /var/www/html/redarc/ 192 | systemctl restart nginx 193 | ``` 194 | 195 | ### 6) Setup submission workers 196 | 197 | Fill in .env files with your own credentials. 198 | 199 | ``` 200 | $ docker pull redis 201 | $ docker run --name some-redis -d redis 202 | $ cd redarc/ingest 203 | $ python -m venv venv 204 | $ source venv/bin/activate 205 | $ pip install rq 206 | $ pip install python-dotenv 207 | $ pip install praw 208 | $ pip install psycopg2-binary 209 | $ pip install gallery-dl 210 | $ python3 ingest/reddit_worker/reddit_worker.py 211 | $ python3 ingest/index_worker/index_worker.py 212 | $ python3 ingest/subreddit_worker/subreddit_worker.py 213 | $ python3 ingest/image_downloader/image_downloader.py 214 | ``` 215 | 216 | # Ingest data: 217 | 218 | ## Postgres: 219 | 220 | Note: Be sure the ingest and Reddit workers are disabled 221 | 222 | Ensure `python3`, `pip` and `psycopg2-binary` are installed: 223 | ``` 224 | # Decompress dumps 225 | 226 | $ unzstd .zst 227 | 228 | $ unzstd .zst 229 | 230 | $ pip install pyscopg2-binary 231 | 232 | # Change database credentials if needed 233 | 234 | $ python3 scripts/load_sub.py 235 | 236 | $ python3 scripts/load_sub_fts.py 237 | 238 | $ python3 scripts/load_comments.py 239 | 240 | $ python3 scripts/load_comments_fts.py 241 | 242 | $ python3 scripts/index.py [subreddit_name] 243 | 244 | # Optional 245 | $ python3 scripts/unlist.py 246 | $ python3 scripts/backfill_images.py 247 | ``` 248 | 249 | ## Web: 250 | 251 | - Submit Reddit URL using the web form `/submit` to be fetched by `reddit_worker` 252 | - Add subreddits to the `SUBREDDITS` envar (delimited by commas) to be periodically fetched by `subreddit_worker` 253 | 254 | # API: 255 | 256 | `search/comments?` 257 | - `[unflatten = ]` 258 | - `[subreddit = ]` 259 | - `[id = ]` 260 | - `[before = ]` 261 | - `[after = ]` 262 | - `[parent_id = ]` 263 | - `[link_id = ]` 264 | - `[sort = ]` 265 | 266 | `search/submissions?` 267 | - `[subreddit = ]` 268 | - `[id = ]` 269 | - `[before = ]` 270 | - `[after = ]` 271 | - `[sort = ]` 272 | 273 | `search/subreddits` 274 | 275 | `search?` 276 | - `>` 277 | - `[before = ]` 278 | - `[after = ]` 279 | - `[sort = ]` 280 | - `[query = ]` 281 | - `>` 282 | 283 | # License: 284 | 285 | Redarc is licensed under the MIT license -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakabuff/redarc/564fe924c00cb59aa624c5cc60690f381b03664d/api/__init__.py -------------------------------------------------------------------------------- /api/app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import redarc_logger 3 | logger = redarc_logger.init_logger('redarc') 4 | logger.info('Starting redarc...') 5 | import falcon 6 | import os 7 | from psycopg2 import pool 8 | import psycopg2 9 | from rq import Queue 10 | from redis import Redis 11 | from submit import Submit 12 | from comments import Comments 13 | from subreddits import Subreddits 14 | from progress import Progress 15 | from submissions import Submissions 16 | from status import Status 17 | from search import Search 18 | from media import Media 19 | from unlist import Unlist 20 | from watch import Watch 21 | from dotenv import load_dotenv 22 | load_dotenv() 23 | 24 | try: 25 | pg_pool = psycopg2.pool.SimpleConnectionPool(1, 20, user=os.getenv('PG_USER'), 26 | password=os.getenv('PG_PASSWORD'), 27 | host=os.getenv('PG_HOST'), 28 | port=os.getenv('PG_PORT'), 29 | database=os.getenv('PG_DATABASE')) 30 | except Exception as error: 31 | logger.error(error) 32 | # https://stackoverflow.com/questions/41071262/how-to-stop-gunicorn-when-flask-application-exits 33 | sys.exit(4) 34 | 35 | app = application = falcon.App(cors_enable=True) 36 | 37 | comments = Comments(pg_pool) 38 | subreddits = Subreddits(pg_pool) 39 | progress = Progress(pg_pool) 40 | submissions = Submissions(pg_pool) 41 | status = Status(pg_pool) 42 | media = Media(os.getenv('IMAGE_PATH')) 43 | unlist = Unlist(pg_pool) 44 | watch = Watch(pg_pool) 45 | 46 | app.add_route('/search/comments', comments) 47 | app.add_route('/search/submissions', submissions) 48 | app.add_route('/search/subreddits', subreddits) 49 | app.add_route('/progress', progress) 50 | app.add_route('/status', status) 51 | app.add_route('/media', media) 52 | app.add_route('/unlist', unlist) 53 | app.add_route('/watch', watch) 54 | 55 | if os.getenv('SEARCH_ENABLED') == "true": 56 | try: 57 | pgfts_pool = psycopg2.pool.SimpleConnectionPool(1, 20, user=os.getenv('PGFTS_USER'), 58 | password=os.getenv('PGFTS_PASSWORD'), 59 | host=os.getenv('PGFTS_HOST'), 60 | port=os.getenv('PGFTS_PORT'), 61 | database=os.getenv('PGFTS_DATABASE')) 62 | except Exception as error: 63 | logger.error(error) 64 | sys.exit(4) 65 | 66 | search = Search(pgfts_pool) 67 | app.add_route('/search', search) 68 | 69 | if os.getenv('INGEST_ENABLED') == "true": 70 | try: 71 | redis_conn = Redis(host=os.getenv('REDIS_HOST'), port=os.getenv('REDIS_PORT')) 72 | except Exception as error: 73 | logger.error(error) 74 | sys.exit(4) 75 | 76 | url_queue = Queue("url_submit", connection=redis_conn) 77 | submit = Submit(url_queue) 78 | app.add_route('/submit', submit) -------------------------------------------------------------------------------- /api/comments.py: -------------------------------------------------------------------------------- 1 | import json 2 | import falcon 3 | from psycopg2.extras import RealDictCursor 4 | import logging 5 | logger = logging.getLogger('redarc') 6 | class Comments: 7 | def __init__(self, pool): 8 | self.pool = pool 9 | 10 | def on_get(self, req, resp): 11 | text = 'SELECT * FROM comments where' 12 | params = [] 13 | 14 | if req.get_param('id'): 15 | text += ' id = %s' 16 | params.append(req.get_param('id')) 17 | 18 | if req.get_param('subreddit'): 19 | if len(params) != 0: 20 | text += ' and' 21 | 22 | params.append(str(req.get_param('subreddit')).lower()) 23 | text += ' subreddit = %s' 24 | 25 | if req.get_param_as_int('after'): 26 | if len(params) != 0: 27 | text += ' and' 28 | 29 | params.append(req.get_param_as_int('after')) 30 | text += ' created_utc > %s' 31 | 32 | if req.get_param_as_int('before'): 33 | if len(params) != 0: 34 | text += ' and' 35 | 36 | params.append(req.get_param_as_int('before')) 37 | text += ' created_utc < %s' 38 | 39 | if req.get_param('parent_id'): 40 | if len(params) != 0: 41 | text += ' and' 42 | 43 | params.append(req.get_param('parent_id')) 44 | text += ' parent_id = %s' 45 | 46 | if req.get_param('link_id'): 47 | if len(params) != 0: 48 | text += ' and' 49 | 50 | params.append(req.get_param('link_id')) 51 | text += ' link_id = %s' 52 | 53 | if req.get_param('sort') == 'ASC': 54 | text += ' ORDER BY created_utc ASC' 55 | else: 56 | text += ' ORDER BY created_utc DESC' 57 | 58 | if not req.get_param('parent_id') and not req.get_param('link_id'): 59 | text += ' LIMIT 500' 60 | 61 | if len(params) == 0: 62 | resp.status = falcon.HTTP_500 63 | return 64 | 65 | try: 66 | pg_con = self.pool.getconn() 67 | cursor = pg_con.cursor(cursor_factory=RealDictCursor) 68 | cursor.execute(text, params) 69 | comments = cursor.fetchall() 70 | except Exception as error: 71 | logger.error(error) 72 | resp.status = falcon.HTTP_500 73 | return 74 | finally: 75 | self.pool.putconn(pg_con) 76 | 77 | if req.get_param_as_bool('unflatten') == True and req.get_param('link_id'): 78 | resp.text = json.dumps(unflatten(comments, req.get_param('link_id'))) 79 | resp.content_type = falcon.MEDIA_JSON 80 | resp.status = falcon.HTTP_200 81 | return 82 | 83 | resp.text= json.dumps(list(comments)) 84 | resp.content_type = falcon.MEDIA_JSON 85 | resp.status = falcon.HTTP_200 86 | 87 | def unflatten(data, root): 88 | """ 89 | Turn array of comments into hashmap. Add empty array field replies to comment obj 90 | Iterate over keys 91 | If comment's parent is root, push to commentTree list. These are top level comments 92 | find parent comment in hashmap and append this comment into parent's reply. 93 | Set replies of root to commentTree 94 | """ 95 | 96 | lookup = array_to_lookup(data) 97 | comment_tree = [] 98 | 99 | for id in lookup: 100 | comment = lookup[id] 101 | parent_id = comment.parent_id 102 | if parent_id == root: 103 | comment_tree.append(comment) 104 | else: 105 | if lookup[parent_id] == None: 106 | comment_tree.append(Comment({'body': "[comment not found]", 'author': "[unknown]", 'id': id, 'replies': [comment], 'parent_id': root, 'link_id': root})) 107 | # print(comment_tree) 108 | return comment_tree 109 | 110 | def array_to_lookup(data): 111 | """ 112 | Turn array of comments into a hashmap id -> comment 113 | """ 114 | lookup = {} 115 | for comment in data: 116 | c = Comment(comment) 117 | lookup[comment['id']] = c 118 | 119 | for i in lookup: 120 | pid = lookup[i].parent_id 121 | if pid in lookup: 122 | lookup[pid].replies.append(lookup[i]) 123 | 124 | return lookup 125 | 126 | 127 | class Comment(dict): 128 | def __init__(self, comment): 129 | super().__init__() 130 | self.__dict__ = self 131 | self.id = comment['id'] 132 | self.author = comment['author'] 133 | self.body = comment['body'] 134 | self.parent_id = comment['parent_id'] 135 | self.link_id = comment['link_id'] 136 | self.subreddit = comment['subreddit'] 137 | self.created_utc = comment['created_utc'] 138 | self.score = comment['score'] 139 | self.gilded = comment['gilded'] 140 | self.replies = [] -------------------------------------------------------------------------------- /api/media.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import os 3 | import falcon 4 | import logging 5 | logger = logging.getLogger('redarc') 6 | 7 | class Media: 8 | def __init__(self, path): 9 | self.base_path = path 10 | 11 | def on_get(self, req, resp): 12 | file = req.get_param('file', required=True) 13 | subreddit = req.get_param('subreddit', required=True) 14 | path = os.path.join(self.base_path, subreddit, file) 15 | try: 16 | content_length = os.path.getsize(path) 17 | resp.stream = open(path, 'rb') 18 | except Exception as error: 19 | logger.error(error) 20 | resp.status = falcon.HTTP_404 21 | return 22 | resp.content_type = mimetypes.guess_type(path)[0] 23 | resp.status = falcon.HTTP_200 24 | resp.content_length = content_length 25 | resp.viewable_as = file -------------------------------------------------------------------------------- /api/progress.py: -------------------------------------------------------------------------------- 1 | import json 2 | import falcon 3 | import os 4 | from psycopg2.extras import RealDictCursor 5 | import logging 6 | logger = logging.getLogger('redarc') 7 | 8 | class Progress: 9 | def __init__(self, pool): 10 | self.pool = pool 11 | 12 | def on_post(self, req, resp): 13 | obj = req.get_media() 14 | if obj.get('password') != os.getenv('ADMIN_PASSWORD'): 15 | try: 16 | pg_con = self.pool.getconn() 17 | cursor = pg_con.cursor(cursor_factory=RealDictCursor) 18 | cursor.execute('SELECT job_id, start_utc, finish_utc, error FROM progress ORDER BY start_utc DESC LIMIT 200') 19 | progress = cursor.fetchall() 20 | except Exception as error: 21 | logger.error(error) 22 | resp.status = falcon.HTTP_500 23 | return 24 | finally: 25 | self.pool.putconn(pg_con) 26 | 27 | resp.text= json.dumps(list(progress)) 28 | resp.content_type = falcon.MEDIA_JSON 29 | resp.status = falcon.HTTP_200 30 | return 31 | 32 | try: 33 | pg_con = self.pool.getconn() 34 | cursor = pg_con.cursor(cursor_factory=RealDictCursor) 35 | cursor.execute('SELECT * FROM progress ORDER BY start_utc DESC LIMIT 200') 36 | progress = cursor.fetchall() 37 | except Exception as error: 38 | logger.error(error) 39 | resp.status = falcon.HTTP_500 40 | return 41 | finally: 42 | self.pool.putconn(pg_con) 43 | 44 | resp.text= json.dumps(list(progress)) 45 | resp.content_type = falcon.MEDIA_JSON 46 | resp.status = falcon.HTTP_200 47 | -------------------------------------------------------------------------------- /api/redarc_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.handlers import RotatingFileHandler 3 | import os 4 | 5 | def init_logger(name): 6 | if not os.path.exists('logs'): 7 | os.makedirs('logs') 8 | logger = logging.getLogger(name) 9 | filename ='logs/redarc_api.log' 10 | handler = RotatingFileHandler(filename, 11 | maxBytes=1024*1024*50, 12 | backupCount=999) 13 | logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', 14 | encoding='utf-8', 15 | level=logging.INFO, 16 | datefmt='%Y-%m-%d %H:%M:%S', 17 | handlers=[handler]) 18 | return logger -------------------------------------------------------------------------------- /api/search.py: -------------------------------------------------------------------------------- 1 | import json 2 | import falcon 3 | from psycopg2.extras import RealDictCursor 4 | import logging 5 | logger = logging.getLogger('redarc') 6 | 7 | COMMENT = "comment" 8 | SUBMISSION = "submission" 9 | 10 | class Search: 11 | def __init__(self, pool): 12 | self.pool = pool 13 | 14 | def on_get(self, req, resp): 15 | type = "" 16 | self.type = req.get_param('type', required=True) 17 | if self.type != "submission" and self.type != "comment": 18 | resp.status = falcon.HTTP_500 19 | return 20 | 21 | self.subreddit= req.get_param('subreddit', required=True) 22 | self.search_phrase = req.get_param('search', required=True) 23 | self.before = req.get_param('before') 24 | if self.before != None and not self.before.isnumeric(): 25 | resp.status = falcon.HTTP_500 26 | return 27 | 28 | self.after = req.get_param('after') 29 | if self.after != None and not self.after.isnumeric(): 30 | resp.status = falcon.HTTP_500 31 | return 32 | 33 | self.search(resp) 34 | 35 | def search(self, resp): 36 | text = '' 37 | if self.type == SUBMISSION: 38 | text = 'SELECT * FROM submissions where' 39 | else: 40 | text = 'SELECT * FROM comments where' 41 | 42 | values = [] 43 | 44 | values.append(self.subreddit.lower()) 45 | text += ' subreddit = %s' 46 | 47 | if self.after: 48 | values.append(self.after) 49 | text += ' and created_utc > %s' 50 | 51 | if self.before: 52 | values.append(self.before) 53 | text += ' and created_utc < %s' 54 | 55 | values.append(self.search_phrase) 56 | text += ' and ts @@ phraseto_tsquery(%s)' 57 | 58 | # if req.get_param('sort') == 'asc': 59 | # text += ' ORDER BY created_utc ASC' 60 | # else: 61 | # text += ' ORDER BY created_utc DESC' 62 | text += ' ORDER BY created_utc DESC' 63 | text += ' LIMIT 100' 64 | 65 | try: 66 | pg_con = self.pool.getconn() 67 | cursor = pg_con.cursor(cursor_factory=RealDictCursor) 68 | cursor.execute(text, values) 69 | results = cursor.fetchall() 70 | except Exception as error: 71 | logger.error(error) 72 | resp.status = falcon.HTTP_500 73 | return 74 | finally: 75 | self.pool.putconn(pg_con) 76 | 77 | resp.text= json.dumps(list(results)) 78 | resp.content_type = falcon.MEDIA_JSON 79 | resp.status = falcon.HTTP_200 -------------------------------------------------------------------------------- /api/status.py: -------------------------------------------------------------------------------- 1 | import json 2 | import falcon 3 | from psycopg2.extras import RealDictCursor 4 | import logging 5 | logger = logging.getLogger('redarc') 6 | 7 | class Status: 8 | def __init__(self, pool): 9 | self.pool = pool 10 | 11 | def on_get(self, req, resp): 12 | try: 13 | pg_con = self.pool.getconn() 14 | cursor = pg_con.cursor(cursor_factory=RealDictCursor) 15 | cursor.execute('SELECT job_id, start_utc, finish_utc, error FROM progress WHERE job_id = %s', [req.get_param('job_id')]) 16 | status = cursor.fetchone() 17 | except Exception as error: 18 | logger.error(error) 19 | resp.status = falcon.HTTP_500 20 | return 21 | finally: 22 | self.pool.putconn(pg_con) 23 | 24 | resp.text= json.dumps([status]) 25 | resp.content_type = falcon.MEDIA_JSON 26 | resp.status = falcon.HTTP_200 27 | -------------------------------------------------------------------------------- /api/submissions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import falcon 3 | from psycopg2.extras import RealDictCursor 4 | import logging 5 | logger = logging.getLogger('redarc') 6 | 7 | class Submissions: 8 | def __init__(self, pool): 9 | self.pool = pool 10 | 11 | def on_get(self, req, resp): 12 | text = 'SELECT * FROM submissions where' 13 | params = [] 14 | 15 | if req.get_param('id'): 16 | text += ' id = %s' 17 | params.append(req.get_param('id')) 18 | 19 | if req.get_param('subreddit'): 20 | if len(params) != 0: 21 | text += ' and' 22 | 23 | params.append(str(req.get_param('subreddit')).lower()) 24 | text += ' subreddit = %s' 25 | 26 | if req.get_param_as_int('after'): 27 | if len(params) != 0: 28 | text += ' and' 29 | 30 | params.append(req.get_param_as_int('after')) 31 | text += ' created_utc > %s' 32 | 33 | if req.get_param_as_int('before'): 34 | if len(params) != 0: 35 | text += ' and' 36 | 37 | params.append(req.get_param_as_int('before')) 38 | text += ' created_utc < %s' 39 | 40 | if req.get_param('sort') == 'ASC': 41 | text += ' ORDER BY created_utc ASC' 42 | else: 43 | text += ' ORDER BY created_utc DESC' 44 | 45 | text += ' LIMIT 100' 46 | 47 | if len(params) == 0: 48 | resp.status = falcon.HTTP_500 49 | return 50 | 51 | try: 52 | pg_con = self.pool.getconn() 53 | cursor = pg_con.cursor(cursor_factory=RealDictCursor) 54 | cursor.execute(text, params) 55 | submissions = cursor.fetchall() 56 | except Exception as error: 57 | logger.error(error) 58 | resp.status = falcon.HTTP_500 59 | return 60 | finally: 61 | self.pool.putconn(pg_con) 62 | 63 | resp.text= json.dumps(list(submissions)) 64 | resp.content_type = falcon.MEDIA_JSON 65 | resp.status = falcon.HTTP_200 66 | -------------------------------------------------------------------------------- /api/submit.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import falcon 4 | import os 5 | import logging 6 | import hashlib 7 | from rq.job import JobStatus 8 | 9 | logger = logging.getLogger('redarc') 10 | 11 | class Submit: 12 | def __init__(self, url_queue): 13 | self.url_queue = url_queue 14 | 15 | def on_post(self, req, resp): 16 | 17 | if os.getenv('INGEST_ENABLED') == 'false': 18 | resp.text = json.dumps({"status": "ingest disabled", "url": ""}) 19 | resp.status = falcon.HTTP_501 20 | return 21 | obj = req.get_media() 22 | url = obj.get('url') 23 | pw = obj.get('password') 24 | 25 | if os.getenv('INGEST_PASSWORD'): 26 | if pw != os.getenv('INGEST_PASSWORD'): 27 | resp.status = falcon.HTTP_401 28 | return 29 | 30 | if 'redd.it' in url: 31 | if re.search(r'\S+redd\.it\/\S+\/?$', url) == None: 32 | resp.text = json.dumps({"status": "invalid url", "url": url}, ensure_ascii=False) 33 | resp.status = falcon.HTTP_500 34 | return 35 | else: 36 | x = url.split('/') 37 | for i in x: 38 | if 'redd.it' in i: 39 | id = x[x.index(i) + 1] 40 | elif 'reddit.com/r/' in url: 41 | if re.search(r'\S+reddit\.com\/r\/\S+\/comments\/\S+\/\S+\/?$', url) == None: 42 | resp.text = json.dumps({"status": "invalid url", "url": url}, ensure_ascii=False) 43 | resp.status = falcon.HTTP_500 44 | return 45 | else: 46 | x = url.split('/') 47 | for i in x: 48 | if 'reddit.com' in i: 49 | id = x[x.index(i) + 4] 50 | else: 51 | resp.text = json.dumps({"status": "invalid url", "url": url}, ensure_ascii=False) 52 | resp.status = falcon.HTTP_500 53 | return 54 | 55 | jid = hashlib.md5(id.encode('utf-8')).hexdigest() 56 | exists = self.job_exists(id) 57 | if exists[0] == True: 58 | resp.text = json.dumps({"status": "success", "id": id, "position": exists[1].get_position()}, ensure_ascii=False) 59 | resp.status = falcon.HTTP_200 60 | return 61 | 62 | try: 63 | job = self.url_queue.enqueue('reddit_worker.fetch_thread', thread_id=id, url=url, job_id=jid) 64 | if job.get_status(refresh=True) == "queued": 65 | resp.text = json.dumps({"status": "success", "id": id, "position": job.get_position()}, ensure_ascii=False) 66 | resp.status = falcon.HTTP_200 67 | else: 68 | logger.error(f"Failed to enqueue job: thread ID {id}") 69 | resp.text = json.dumps({"status": "failed", "id": id}, ensure_ascii=False) 70 | resp.status = falcon.HTTP_500 71 | except Exception as error: 72 | logger.error(f"Failed to enqueue job: thread ID {id}") 73 | logger.error(error) 74 | resp.status = falcon.HTTP_500 75 | resp.text = json.dumps({"status": "failed"}, ensure_ascii=False) 76 | 77 | def job_exists(self, id): 78 | job = self.url_queue.fetch_job(id) 79 | if job == None: 80 | return (False, None) 81 | status = job.get_status() 82 | if status in {JobStatus.QUEUED, JobStatus.SCHEDULED}: 83 | # Job exists and will be run. 84 | return (True, job) 85 | return (False, None) -------------------------------------------------------------------------------- /api/subreddits.py: -------------------------------------------------------------------------------- 1 | import json 2 | import falcon 3 | from psycopg2.extras import RealDictCursor 4 | import logging 5 | logger = logging.getLogger('redarc') 6 | 7 | class Subreddits: 8 | def __init__(self, pool): 9 | self.pool = pool 10 | 11 | def on_get(self, req, resp): 12 | try: 13 | pg_con = self.pool.getconn() 14 | cursor = pg_con.cursor(cursor_factory=RealDictCursor) 15 | cursor.execute('select * from subreddits') 16 | subs = cursor.fetchall() 17 | except Exception as error: 18 | logger.error(error) 19 | resp.status = falcon.HTTP_500 20 | return 21 | finally: 22 | self.pool.putconn(pg_con) 23 | 24 | s = filter(lambda x: (x['unlisted'] == False), subs) 25 | out = json.dumps(list(s)) 26 | resp.text= out 27 | resp.content_type = falcon.MEDIA_JSON 28 | resp.status = falcon.HTTP_200 29 | -------------------------------------------------------------------------------- /api/unlist.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import falcon 4 | from psycopg2.extras import RealDictCursor 5 | import logging 6 | logger = logging.getLogger('redarc') 7 | 8 | class Unlist: 9 | def __init__(self, pool): 10 | self.pool = pool 11 | 12 | def on_post(self, req, resp): 13 | obj = req.get_media() 14 | subreddit = obj.get('subreddit') 15 | unlist = obj.get('unlist') 16 | pw = obj.get('password') 17 | 18 | if pw != os.getenv('ADMIN_PASSWORD'): 19 | resp.status = falcon.HTTP_401 20 | return 21 | 22 | try: 23 | pg_con = self.pool.getconn() 24 | cursor = pg_con.cursor(cursor_factory=RealDictCursor) 25 | cursor.execute('UPDATE subreddits SET unlisted = %s WHERE name = %s', [unlist, subreddit]) 26 | pg_con.commit() 27 | except Exception as error: 28 | logger.error(error) 29 | resp.status = falcon.HTTP_500 30 | return 31 | finally: 32 | self.pool.putconn(pg_con) 33 | 34 | resp.status = falcon.HTTP_200 35 | -------------------------------------------------------------------------------- /api/watch.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import falcon 4 | from psycopg2.extras import RealDictCursor 5 | import logging 6 | logger = logging.getLogger('redarc') 7 | 8 | class Watch: 9 | def __init__(self, pool): 10 | self.pool = pool 11 | 12 | def on_post(self, req, resp): 13 | obj = req.get_media() 14 | subreddit = obj.get('subreddit') 15 | action = obj.get('action') 16 | pw = obj.get('password') 17 | 18 | if pw != os.getenv('ADMIN_PASSWORD'): 19 | resp.status = falcon.HTTP_401 20 | return 21 | 22 | if action != "add" and action != "remove": 23 | resp.status = falcon.HTTP_500 24 | return 25 | 26 | try: 27 | pg_con = self.pool.getconn() 28 | cursor = pg_con.cursor(cursor_factory=RealDictCursor) 29 | if action == "add": 30 | cursor.execute('INSERT INTO watch(name) VALUES(%s) ON CONFLICT (name) DO NOTHING', [subreddit]) 31 | else: 32 | cursor.execute('DELETE FROM watch where name = %s', [subreddit]) 33 | pg_con.commit() 34 | except Exception as error: 35 | logger.error(error) 36 | resp.status = falcon.HTTP_500 37 | return 38 | finally: 39 | self.pool.putconn(pg_con) 40 | 41 | resp.status = falcon.HTTP_200 42 | -------------------------------------------------------------------------------- /default.env: -------------------------------------------------------------------------------- 1 | REDARC_API=http://redarc.mysite.org/api/ 2 | REDARC_FE_API=http://redarc.mysite.org/api/ 3 | SERVER_NAME=redarc.mysite.org 4 | ADMIN_PASSWORD="qwerty" 5 | INGEST_PASSWORD="asdf" 6 | IMAGE_PATH="/your/path" 7 | 8 | INGEST_ENABLED=true 9 | INDEX_ENABLED=true 10 | SEARCH_ENABLED=true 11 | PG_DATABASE=postgres 12 | PG_USER=postgres 13 | PG_PASSWORD=test1234 14 | PG_HOST=pgsql-dev 15 | PG_PORT=5432 16 | PGFTS_DATABASE=postgres 17 | PGFTS_USER=postgres 18 | PGFTS_PASSWORD=test1234 19 | PGFTS_HOST=pgsql-fts 20 | PGFTS_PORT=5432 21 | 22 | CLIENT_ID="change me" 23 | CLIENT_SECRET="change me" 24 | PASSWORD="change me" 25 | USER_AGENT="my user agent" 26 | REDDIT_USERNAME="change me" 27 | INDEX_DELAY=300 28 | SUBREDDITS="asdf,asdf" 29 | FETCH_DELAY=43200 30 | DOWNLOAD_IMAGES=true 31 | REDIS_HOST=localhost 32 | REDIS_PORT=6379 33 | NUM_THREADS=25 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 5 | postgres: 6 | image: postgres 7 | container_name: pgsql-dev 8 | networks: 9 | - redarc 10 | environment: 11 | POSTGRES_PASSWORD: test1234 12 | volumes: 13 | - pgredarc01:/var/lib/postgresql/data 14 | ports: 15 | - "5432:5432" 16 | healthcheck: 17 | test: ["CMD", "pg_isready", "-h", "pgsql-dev", "-U", "postgres"] 18 | timeout: 10s 19 | retries: 10 20 | 21 | postgres_fts: 22 | image: postgres 23 | container_name: pgsql-fts 24 | networks: 25 | - redarc 26 | environment: 27 | POSTGRES_PASSWORD: test1234 28 | volumes: 29 | - pgftsredarc01:/var/lib/postgresql/data 30 | ports: 31 | - "5433:5432" 32 | healthcheck: 33 | test: ["CMD", "pg_isready", "-h", "pgsql-fts", "-U", "postgres"] 34 | timeout: 10s 35 | retries: 10 36 | 37 | redarc: 38 | build: 39 | context: . 40 | dockerfile: Dockerfile 41 | image: redarc 42 | container_name: redarc 43 | networks: 44 | - redarc 45 | env_file: 46 | - .env 47 | volumes: 48 | - redarc_api_logs:/redarc/api/logs 49 | - redarc_images:/ingest/gallery-dl 50 | ports: 51 | - "80:80" 52 | depends_on: 53 | postgres: 54 | condition: service_healthy 55 | postgres_fts: 56 | condition: service_healthy 57 | redis: 58 | condition: service_healthy 59 | 60 | redis: 61 | image: redis:7.0.12-alpine3.18 62 | container_name: redis 63 | networks: 64 | - redarc 65 | command: redis-server 66 | healthcheck: 67 | test: ["CMD", "redis-cli","ping"] 68 | timeout: 10s 69 | retries: 10 70 | 71 | image_downloader: 72 | build: 73 | context: ./ingest/image_downloader 74 | dockerfile: Dockerfile 75 | image: image_downloader 76 | container_name: image_downloader 77 | networks: 78 | - redarc 79 | env_file: 80 | - .env 81 | volumes: 82 | - redarc_ingest_logs:/image_downloader/logs 83 | - redarc_images:/image_downloader/gallery-dl 84 | depends_on: 85 | redis: 86 | condition: service_healthy 87 | 88 | index_worker: 89 | build: 90 | context: ./ingest/index_worker 91 | dockerfile: Dockerfile 92 | image: index_worker 93 | container_name: index_worker 94 | networks: 95 | - redarc 96 | env_file: 97 | - .env 98 | volumes: 99 | - redarc_ingest_logs:/index_worker/logs 100 | depends_on: 101 | postgres: 102 | condition: service_healthy 103 | postgres_fts: 104 | condition: service_healthy 105 | 106 | reddit_worker: 107 | build: 108 | context: ./ingest/reddit_worker 109 | dockerfile: Dockerfile 110 | image: reddit_worker 111 | container_name: reddit_worker 112 | networks: 113 | - redarc 114 | env_file: 115 | - .env 116 | volumes: 117 | - redarc_ingest_logs:/reddit_worker/logs 118 | depends_on: 119 | postgres: 120 | condition: service_healthy 121 | redis: 122 | condition: service_healthy 123 | 124 | subreddit_worker: 125 | build: 126 | context: ./ingest/subreddit_worker 127 | dockerfile: Dockerfile 128 | image: subreddit_worker 129 | container_name: subreddit_worker 130 | networks: 131 | - redarc 132 | env_file: 133 | - .env 134 | volumes: 135 | - redarc_ingest_logs:/subreddit_worker/logs 136 | depends_on: 137 | redis: 138 | condition: service_healthy 139 | 140 | networks: 141 | redarc: 142 | driver: bridge 143 | name: redarc 144 | 145 | volumes: 146 | pgredarc01: 147 | driver: local 148 | pgftsredarc01: 149 | driver: local 150 | redarc_ingest_logs: 151 | driver: local 152 | redarc_api_logs: 153 | driver: local 154 | redarc_images: 155 | driver: local -------------------------------------------------------------------------------- /docs/redarc_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakabuff/redarc/564fe924c00cb59aa624c5cc60690f381b03664d/docs/redarc_architecture.png -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakabuff/redarc/564fe924c00cb59aa624c5cc60690f381b03664d/docs/screenshot.png -------------------------------------------------------------------------------- /docs/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakabuff/redarc/564fe924c00cb59aa624c5cc60690f381b03664d/docs/screenshot2.png -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:react/recommended', 6 | 'plugin:react/jsx-runtime', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | settings: { react: { version: '18.2' } }, 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': 'warn', 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .env -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Redarc 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redarc-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "react-router-dom": "^6.11.1" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.0.28", 19 | "@types/react-dom": "^18.0.11", 20 | "@vitejs/plugin-react": "^4.0.0", 21 | "eslint": "^8.38.0", 22 | "eslint-plugin-react": "^7.32.2", 23 | "eslint-plugin-react-hooks": "^4.6.0", 24 | "eslint-plugin-react-refresh": "^0.3.4", 25 | "vite": "^4.3.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/public/bootstrap/css/bootstrap-responsive.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Responsive v2.3.2 3 | * 4 | * Copyright 2013 Twitter, Inc 5 | * Licensed under the Apache License v2.0 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Designed and built with all the love in the world by @mdo and @fat. 9 | */.clearfix{*zoom:1}.clearfix:before,.clearfix:after{display:table;line-height:0;content:""}.clearfix:after{clear:both}.hide-text{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.input-block-level{display:block;width:100%;min-height:30px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}@-ms-viewport{width:device-width}.hidden{display:none;visibility:hidden}.visible-phone{display:none!important}.visible-tablet{display:none!important}.hidden-desktop{display:none!important}.visible-desktop{display:inherit!important}@media(min-width:768px) and (max-width:979px){.hidden-desktop{display:inherit!important}.visible-desktop{display:none!important}.visible-tablet{display:inherit!important}.hidden-tablet{display:none!important}}@media(max-width:767px){.hidden-desktop{display:inherit!important}.visible-desktop{display:none!important}.visible-phone{display:inherit!important}.hidden-phone{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:inherit!important}.hidden-print{display:none!important}}@media(min-width:1200px){.row{margin-left:-30px;*zoom:1}.row:before,.row:after{display:table;line-height:0;content:""}.row:after{clear:both}[class*="span"]{float:left;min-height:1px;margin-left:30px}.container,.navbar-static-top .container,.navbar-fixed-top .container,.navbar-fixed-bottom .container{width:1170px}.span12{width:1170px}.span11{width:1070px}.span10{width:970px}.span9{width:870px}.span8{width:770px}.span7{width:670px}.span6{width:570px}.span5{width:470px}.span4{width:370px}.span3{width:270px}.span2{width:170px}.span1{width:70px}.offset12{margin-left:1230px}.offset11{margin-left:1130px}.offset10{margin-left:1030px}.offset9{margin-left:930px}.offset8{margin-left:830px}.offset7{margin-left:730px}.offset6{margin-left:630px}.offset5{margin-left:530px}.offset4{margin-left:430px}.offset3{margin-left:330px}.offset2{margin-left:230px}.offset1{margin-left:130px}.row-fluid{width:100%;*zoom:1}.row-fluid:before,.row-fluid:after{display:table;line-height:0;content:""}.row-fluid:after{clear:both}.row-fluid [class*="span"]{display:block;float:left;width:100%;min-height:30px;margin-left:2.564102564102564%;*margin-left:2.5109110747408616%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.row-fluid [class*="span"]:first-child{margin-left:0}.row-fluid .controls-row [class*="span"]+[class*="span"]{margin-left:2.564102564102564%}.row-fluid .span12{width:100%;*width:99.94680851063829%}.row-fluid .span11{width:91.45299145299145%;*width:91.39979996362975%}.row-fluid .span10{width:82.90598290598291%;*width:82.8527914166212%}.row-fluid .span9{width:74.35897435897436%;*width:74.30578286961266%}.row-fluid .span8{width:65.81196581196582%;*width:65.75877432260411%}.row-fluid .span7{width:57.26495726495726%;*width:57.21176577559556%}.row-fluid .span6{width:48.717948717948715%;*width:48.664757228587014%}.row-fluid .span5{width:40.17094017094017%;*width:40.11774868157847%}.row-fluid .span4{width:31.623931623931625%;*width:31.570740134569924%}.row-fluid .span3{width:23.076923076923077%;*width:23.023731587561375%}.row-fluid .span2{width:14.52991452991453%;*width:14.476723040552828%}.row-fluid .span1{width:5.982905982905983%;*width:5.929714493544281%}.row-fluid .offset12{margin-left:105.12820512820512%;*margin-left:105.02182214948171%}.row-fluid .offset12:first-child{margin-left:102.56410256410257%;*margin-left:102.45771958537915%}.row-fluid .offset11{margin-left:96.58119658119658%;*margin-left:96.47481360247316%}.row-fluid .offset11:first-child{margin-left:94.01709401709402%;*margin-left:93.91071103837061%}.row-fluid .offset10{margin-left:88.03418803418803%;*margin-left:87.92780505546462%}.row-fluid .offset10:first-child{margin-left:85.47008547008548%;*margin-left:85.36370249136206%}.row-fluid .offset9{margin-left:79.48717948717949%;*margin-left:79.38079650845607%}.row-fluid .offset9:first-child{margin-left:76.92307692307693%;*margin-left:76.81669394435352%}.row-fluid .offset8{margin-left:70.94017094017094%;*margin-left:70.83378796144753%}.row-fluid .offset8:first-child{margin-left:68.37606837606839%;*margin-left:68.26968539734497%}.row-fluid .offset7{margin-left:62.393162393162385%;*margin-left:62.28677941443899%}.row-fluid .offset7:first-child{margin-left:59.82905982905982%;*margin-left:59.72267685033642%}.row-fluid .offset6{margin-left:53.84615384615384%;*margin-left:53.739770867430444%}.row-fluid .offset6:first-child{margin-left:51.28205128205128%;*margin-left:51.175668303327875%}.row-fluid .offset5{margin-left:45.299145299145295%;*margin-left:45.1927623204219%}.row-fluid .offset5:first-child{margin-left:42.73504273504273%;*margin-left:42.62865975631933%}.row-fluid .offset4{margin-left:36.75213675213675%;*margin-left:36.645753773413354%}.row-fluid .offset4:first-child{margin-left:34.18803418803419%;*margin-left:34.081651209310785%}.row-fluid .offset3{margin-left:28.205128205128204%;*margin-left:28.0987452264048%}.row-fluid .offset3:first-child{margin-left:25.641025641025642%;*margin-left:25.53464266230224%}.row-fluid .offset2{margin-left:19.65811965811966%;*margin-left:19.551736679396257%}.row-fluid .offset2:first-child{margin-left:17.094017094017094%;*margin-left:16.98763411529369%}.row-fluid .offset1{margin-left:11.11111111111111%;*margin-left:11.004728132387708%}.row-fluid .offset1:first-child{margin-left:8.547008547008547%;*margin-left:8.440625568285142%}input,textarea,.uneditable-input{margin-left:0}.controls-row [class*="span"]+[class*="span"]{margin-left:30px}input.span12,textarea.span12,.uneditable-input.span12{width:1156px}input.span11,textarea.span11,.uneditable-input.span11{width:1056px}input.span10,textarea.span10,.uneditable-input.span10{width:956px}input.span9,textarea.span9,.uneditable-input.span9{width:856px}input.span8,textarea.span8,.uneditable-input.span8{width:756px}input.span7,textarea.span7,.uneditable-input.span7{width:656px}input.span6,textarea.span6,.uneditable-input.span6{width:556px}input.span5,textarea.span5,.uneditable-input.span5{width:456px}input.span4,textarea.span4,.uneditable-input.span4{width:356px}input.span3,textarea.span3,.uneditable-input.span3{width:256px}input.span2,textarea.span2,.uneditable-input.span2{width:156px}input.span1,textarea.span1,.uneditable-input.span1{width:56px}.thumbnails{margin-left:-30px}.thumbnails>li{margin-left:30px}.row-fluid .thumbnails{margin-left:0}}@media(min-width:768px) and (max-width:979px){.row{margin-left:-20px;*zoom:1}.row:before,.row:after{display:table;line-height:0;content:""}.row:after{clear:both}[class*="span"]{float:left;min-height:1px;margin-left:20px}.container,.navbar-static-top .container,.navbar-fixed-top .container,.navbar-fixed-bottom .container{width:724px}.span12{width:724px}.span11{width:662px}.span10{width:600px}.span9{width:538px}.span8{width:476px}.span7{width:414px}.span6{width:352px}.span5{width:290px}.span4{width:228px}.span3{width:166px}.span2{width:104px}.span1{width:42px}.offset12{margin-left:764px}.offset11{margin-left:702px}.offset10{margin-left:640px}.offset9{margin-left:578px}.offset8{margin-left:516px}.offset7{margin-left:454px}.offset6{margin-left:392px}.offset5{margin-left:330px}.offset4{margin-left:268px}.offset3{margin-left:206px}.offset2{margin-left:144px}.offset1{margin-left:82px}.row-fluid{width:100%;*zoom:1}.row-fluid:before,.row-fluid:after{display:table;line-height:0;content:""}.row-fluid:after{clear:both}.row-fluid [class*="span"]{display:block;float:left;width:100%;min-height:30px;margin-left:2.7624309392265194%;*margin-left:2.709239449864817%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.row-fluid [class*="span"]:first-child{margin-left:0}.row-fluid .controls-row [class*="span"]+[class*="span"]{margin-left:2.7624309392265194%}.row-fluid .span12{width:100%;*width:99.94680851063829%}.row-fluid .span11{width:91.43646408839778%;*width:91.38327259903608%}.row-fluid .span10{width:82.87292817679558%;*width:82.81973668743387%}.row-fluid .span9{width:74.30939226519337%;*width:74.25620077583166%}.row-fluid .span8{width:65.74585635359117%;*width:65.69266486422946%}.row-fluid .span7{width:57.18232044198895%;*width:57.12912895262725%}.row-fluid .span6{width:48.61878453038674%;*width:48.56559304102504%}.row-fluid .span5{width:40.05524861878453%;*width:40.00205712942283%}.row-fluid .span4{width:31.491712707182323%;*width:31.43852121782062%}.row-fluid .span3{width:22.92817679558011%;*width:22.87498530621841%}.row-fluid .span2{width:14.3646408839779%;*width:14.311449394616199%}.row-fluid .span1{width:5.801104972375691%;*width:5.747913483013988%}.row-fluid .offset12{margin-left:105.52486187845304%;*margin-left:105.41847889972962%}.row-fluid .offset12:first-child{margin-left:102.76243093922652%;*margin-left:102.6560479605031%}.row-fluid .offset11{margin-left:96.96132596685082%;*margin-left:96.8549429881274%}.row-fluid .offset11:first-child{margin-left:94.1988950276243%;*margin-left:94.09251204890089%}.row-fluid .offset10{margin-left:88.39779005524862%;*margin-left:88.2914070765252%}.row-fluid .offset10:first-child{margin-left:85.6353591160221%;*margin-left:85.52897613729868%}.row-fluid .offset9{margin-left:79.8342541436464%;*margin-left:79.72787116492299%}.row-fluid .offset9:first-child{margin-left:77.07182320441989%;*margin-left:76.96544022569647%}.row-fluid .offset8{margin-left:71.2707182320442%;*margin-left:71.16433525332079%}.row-fluid .offset8:first-child{margin-left:68.50828729281768%;*margin-left:68.40190431409427%}.row-fluid .offset7{margin-left:62.70718232044199%;*margin-left:62.600799341718584%}.row-fluid .offset7:first-child{margin-left:59.94475138121547%;*margin-left:59.838368402492065%}.row-fluid .offset6{margin-left:54.14364640883978%;*margin-left:54.037263430116376%}.row-fluid .offset6:first-child{margin-left:51.38121546961326%;*margin-left:51.27483249088986%}.row-fluid .offset5{margin-left:45.58011049723757%;*margin-left:45.47372751851417%}.row-fluid .offset5:first-child{margin-left:42.81767955801105%;*margin-left:42.71129657928765%}.row-fluid .offset4{margin-left:37.01657458563536%;*margin-left:36.91019160691196%}.row-fluid .offset4:first-child{margin-left:34.25414364640884%;*margin-left:34.14776066768544%}.row-fluid .offset3{margin-left:28.45303867403315%;*margin-left:28.346655695309746%}.row-fluid .offset3:first-child{margin-left:25.69060773480663%;*margin-left:25.584224756083227%}.row-fluid .offset2{margin-left:19.88950276243094%;*margin-left:19.783119783707537%}.row-fluid .offset2:first-child{margin-left:17.12707182320442%;*margin-left:17.02068884448102%}.row-fluid .offset1{margin-left:11.32596685082873%;*margin-left:11.219583872105325%}.row-fluid .offset1:first-child{margin-left:8.56353591160221%;*margin-left:8.457152932878806%}input,textarea,.uneditable-input{margin-left:0}.controls-row [class*="span"]+[class*="span"]{margin-left:20px}input.span12,textarea.span12,.uneditable-input.span12{width:710px}input.span11,textarea.span11,.uneditable-input.span11{width:648px}input.span10,textarea.span10,.uneditable-input.span10{width:586px}input.span9,textarea.span9,.uneditable-input.span9{width:524px}input.span8,textarea.span8,.uneditable-input.span8{width:462px}input.span7,textarea.span7,.uneditable-input.span7{width:400px}input.span6,textarea.span6,.uneditable-input.span6{width:338px}input.span5,textarea.span5,.uneditable-input.span5{width:276px}input.span4,textarea.span4,.uneditable-input.span4{width:214px}input.span3,textarea.span3,.uneditable-input.span3{width:152px}input.span2,textarea.span2,.uneditable-input.span2{width:90px}input.span1,textarea.span1,.uneditable-input.span1{width:28px}}@media(max-width:767px){body{padding-right:20px;padding-left:20px}.navbar-fixed-top,.navbar-fixed-bottom,.navbar-static-top{margin-right:-20px;margin-left:-20px}.container-fluid{padding:0}.dl-horizontal dt{float:none;width:auto;clear:none;text-align:left}.dl-horizontal dd{margin-left:0}.container{width:auto}.row-fluid{width:100%}.row,.thumbnails{margin-left:0}.thumbnails>li{float:none;margin-left:0}[class*="span"],.uneditable-input[class*="span"],.row-fluid [class*="span"]{display:block;float:none;width:100%;margin-left:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.span12,.row-fluid .span12{width:100%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.row-fluid [class*="offset"]:first-child{margin-left:0}.input-large,.input-xlarge,.input-xxlarge,input[class*="span"],select[class*="span"],textarea[class*="span"],.uneditable-input{display:block;width:100%;min-height:30px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.input-prepend input,.input-append input,.input-prepend input[class*="span"],.input-append input[class*="span"]{display:inline-block;width:auto}.controls-row [class*="span"]+[class*="span"]{margin-left:0}.modal{position:fixed;top:20px;right:20px;left:20px;width:auto;margin:0}.modal.fade{top:-100px}.modal.fade.in{top:20px}}@media(max-width:480px){.nav-collapse{-webkit-transform:translate3d(0,0,0)}.page-header h1 small{display:block;line-height:20px}input[type="checkbox"],input[type="radio"]{border:1px solid #ccc}.form-horizontal .control-label{float:none;width:auto;padding-top:0;text-align:left}.form-horizontal .controls{margin-left:0}.form-horizontal .control-list{padding-top:0}.form-horizontal .form-actions{padding-right:10px;padding-left:10px}.media .pull-left,.media .pull-right{display:block;float:none;margin-bottom:10px}.media-object{margin-right:0;margin-left:0}.modal{top:10px;right:10px;left:10px}.modal-header .close{padding:10px;margin:-10px}.carousel-caption{position:static}}@media(max-width:979px){body{padding-top:0}.navbar-fixed-top,.navbar-fixed-bottom{position:static}.navbar-fixed-top{margin-bottom:20px}.navbar-fixed-bottom{margin-top:20px}.navbar-fixed-top .navbar-inner,.navbar-fixed-bottom .navbar-inner{padding:5px}.navbar .container{width:auto;padding:0}.navbar .brand{padding-right:10px;padding-left:10px;margin:0 0 0 -5px}.nav-collapse{clear:both}.nav-collapse .nav{float:none;margin:0 0 10px}.nav-collapse .nav>li{float:none}.nav-collapse .nav>li>a{margin-bottom:2px}.nav-collapse .nav>.divider-vertical{display:none}.nav-collapse .nav .nav-header{color:#777;text-shadow:none}.nav-collapse .nav>li>a,.nav-collapse .dropdown-menu a{padding:9px 15px;font-weight:bold;color:#777;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px}.nav-collapse .btn{padding:4px 10px 4px;font-weight:normal;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px}.nav-collapse .dropdown-menu li+li a{margin-bottom:2px}.nav-collapse .nav>li>a:hover,.nav-collapse .nav>li>a:focus,.nav-collapse .dropdown-menu a:hover,.nav-collapse .dropdown-menu a:focus{background-color:#f2f2f2}.navbar-inverse .nav-collapse .nav>li>a,.navbar-inverse .nav-collapse .dropdown-menu a{color:#999}.navbar-inverse .nav-collapse .nav>li>a:hover,.navbar-inverse .nav-collapse .nav>li>a:focus,.navbar-inverse .nav-collapse .dropdown-menu a:hover,.navbar-inverse .nav-collapse .dropdown-menu a:focus{background-color:#111}.nav-collapse.in .btn-group{padding:0;margin-top:5px}.nav-collapse .dropdown-menu{position:static;top:auto;left:auto;display:none;float:none;max-width:none;padding:0;margin:0 15px;background-color:transparent;border:0;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none}.nav-collapse .open>.dropdown-menu{display:block}.nav-collapse .dropdown-menu:before,.nav-collapse .dropdown-menu:after{display:none}.nav-collapse .dropdown-menu .divider{display:none}.nav-collapse .nav>li>.dropdown-menu:before,.nav-collapse .nav>li>.dropdown-menu:after{display:none}.nav-collapse .navbar-form,.nav-collapse .navbar-search{float:none;padding:10px 15px;margin:10px 0;border-top:1px solid #f2f2f2;border-bottom:1px solid #f2f2f2;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);-moz-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1)}.navbar-inverse .nav-collapse .navbar-form,.navbar-inverse .nav-collapse .navbar-search{border-top-color:#111;border-bottom-color:#111}.navbar .nav-collapse .nav.pull-right{float:none;margin-left:0}.nav-collapse,.nav-collapse.collapse{height:0;overflow:hidden}.navbar .btn-navbar{display:block}.navbar-static .navbar-inner{padding-right:10px;padding-left:10px}}@media(min-width:980px){.nav-collapse.collapse{height:auto!important;overflow:visible!important}} 10 | -------------------------------------------------------------------------------- /frontend/public/bootstrap/img/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakabuff/redarc/564fe924c00cb59aa624c5cc60690f381b03664d/frontend/public/bootstrap/img/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /frontend/public/bootstrap/img/glyphicons-halflings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakabuff/redarc/564fe924c00cb59aa624c5cc60690f381b03664d/frontend/public/bootstrap/img/glyphicons-halflings.png -------------------------------------------------------------------------------- /frontend/public/bootstrap/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap.js by @fat & @mdo 3 | * Copyright 2013 Twitter, Inc. 4 | * http://www.apache.org/licenses/LICENSE-2.0.txt 5 | */ 6 | !function(e){"use strict";e(function(){e.support.transition=function(){var e=function(){var e=document.createElement("bootstrap"),t={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"},n;for(n in t)if(e.style[n]!==undefined)return t[n]}();return e&&{end:e}}()})}(window.jQuery),!function(e){"use strict";var t='[data-dismiss="alert"]',n=function(n){e(n).on("click",t,this.close)};n.prototype.close=function(t){function s(){i.trigger("closed").remove()}var n=e(this),r=n.attr("data-target"),i;r||(r=n.attr("href"),r=r&&r.replace(/.*(?=#[^\s]*$)/,"")),i=e(r),t&&t.preventDefault(),i.length||(i=n.hasClass("alert")?n:n.parent()),i.trigger(t=e.Event("close"));if(t.isDefaultPrevented())return;i.removeClass("in"),e.support.transition&&i.hasClass("fade")?i.on(e.support.transition.end,s):s()};var r=e.fn.alert;e.fn.alert=function(t){return this.each(function(){var r=e(this),i=r.data("alert");i||r.data("alert",i=new n(this)),typeof t=="string"&&i[t].call(r)})},e.fn.alert.Constructor=n,e.fn.alert.noConflict=function(){return e.fn.alert=r,this},e(document).on("click.alert.data-api",t,n.prototype.close)}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=e.extend({},e.fn.button.defaults,n)};t.prototype.setState=function(e){var t="disabled",n=this.$element,r=n.data(),i=n.is("input")?"val":"html";e+="Text",r.resetText||n.data("resetText",n[i]()),n[i](r[e]||this.options[e]),setTimeout(function(){e=="loadingText"?n.addClass(t).attr(t,t):n.removeClass(t).removeAttr(t)},0)},t.prototype.toggle=function(){var e=this.$element.closest('[data-toggle="buttons-radio"]');e&&e.find(".active").removeClass("active"),this.$element.toggleClass("active")};var n=e.fn.button;e.fn.button=function(n){return this.each(function(){var r=e(this),i=r.data("button"),s=typeof n=="object"&&n;i||r.data("button",i=new t(this,s)),n=="toggle"?i.toggle():n&&i.setState(n)})},e.fn.button.defaults={loadingText:"loading..."},e.fn.button.Constructor=t,e.fn.button.noConflict=function(){return e.fn.button=n,this},e(document).on("click.button.data-api","[data-toggle^=button]",function(t){var n=e(t.target);n.hasClass("btn")||(n=n.closest(".btn")),n.button("toggle")})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.$indicators=this.$element.find(".carousel-indicators"),this.options=n,this.options.pause=="hover"&&this.$element.on("mouseenter",e.proxy(this.pause,this)).on("mouseleave",e.proxy(this.cycle,this))};t.prototype={cycle:function(t){return t||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(e.proxy(this.next,this),this.options.interval)),this},getActiveIndex:function(){return this.$active=this.$element.find(".item.active"),this.$items=this.$active.parent().children(),this.$items.index(this.$active)},to:function(t){var n=this.getActiveIndex(),r=this;if(t>this.$items.length-1||t<0)return;return this.sliding?this.$element.one("slid",function(){r.to(t)}):n==t?this.pause().cycle():this.slide(t>n?"next":"prev",e(this.$items[t]))},pause:function(t){return t||(this.paused=!0),this.$element.find(".next, .prev").length&&e.support.transition.end&&(this.$element.trigger(e.support.transition.end),this.cycle(!0)),clearInterval(this.interval),this.interval=null,this},next:function(){if(this.sliding)return;return this.slide("next")},prev:function(){if(this.sliding)return;return this.slide("prev")},slide:function(t,n){var r=this.$element.find(".item.active"),i=n||r[t](),s=this.interval,o=t=="next"?"left":"right",u=t=="next"?"first":"last",a=this,f;this.sliding=!0,s&&this.pause(),i=i.length?i:this.$element.find(".item")[u](),f=e.Event("slide",{relatedTarget:i[0],direction:o});if(i.hasClass("active"))return;this.$indicators.length&&(this.$indicators.find(".active").removeClass("active"),this.$element.one("slid",function(){var t=e(a.$indicators.children()[a.getActiveIndex()]);t&&t.addClass("active")}));if(e.support.transition&&this.$element.hasClass("slide")){this.$element.trigger(f);if(f.isDefaultPrevented())return;i.addClass(t),i[0].offsetWidth,r.addClass(o),i.addClass(o),this.$element.one(e.support.transition.end,function(){i.removeClass([t,o].join(" ")).addClass("active"),r.removeClass(["active",o].join(" ")),a.sliding=!1,setTimeout(function(){a.$element.trigger("slid")},0)})}else{this.$element.trigger(f);if(f.isDefaultPrevented())return;r.removeClass("active"),i.addClass("active"),this.sliding=!1,this.$element.trigger("slid")}return s&&this.cycle(),this}};var n=e.fn.carousel;e.fn.carousel=function(n){return this.each(function(){var r=e(this),i=r.data("carousel"),s=e.extend({},e.fn.carousel.defaults,typeof n=="object"&&n),o=typeof n=="string"?n:s.slide;i||r.data("carousel",i=new t(this,s)),typeof n=="number"?i.to(n):o?i[o]():s.interval&&i.pause().cycle()})},e.fn.carousel.defaults={interval:5e3,pause:"hover"},e.fn.carousel.Constructor=t,e.fn.carousel.noConflict=function(){return e.fn.carousel=n,this},e(document).on("click.carousel.data-api","[data-slide], [data-slide-to]",function(t){var n=e(this),r,i=e(n.attr("data-target")||(r=n.attr("href"))&&r.replace(/.*(?=#[^\s]+$)/,"")),s=e.extend({},i.data(),n.data()),o;i.carousel(s),(o=n.attr("data-slide-to"))&&i.data("carousel").pause().to(o).cycle(),t.preventDefault()})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=e.extend({},e.fn.collapse.defaults,n),this.options.parent&&(this.$parent=e(this.options.parent)),this.options.toggle&&this.toggle()};t.prototype={constructor:t,dimension:function(){var e=this.$element.hasClass("width");return e?"width":"height"},show:function(){var t,n,r,i;if(this.transitioning||this.$element.hasClass("in"))return;t=this.dimension(),n=e.camelCase(["scroll",t].join("-")),r=this.$parent&&this.$parent.find("> .accordion-group > .in");if(r&&r.length){i=r.data("collapse");if(i&&i.transitioning)return;r.collapse("hide"),i||r.data("collapse",null)}this.$element[t](0),this.transition("addClass",e.Event("show"),"shown"),e.support.transition&&this.$element[t](this.$element[0][n])},hide:function(){var t;if(this.transitioning||!this.$element.hasClass("in"))return;t=this.dimension(),this.reset(this.$element[t]()),this.transition("removeClass",e.Event("hide"),"hidden"),this.$element[t](0)},reset:function(e){var t=this.dimension();return this.$element.removeClass("collapse")[t](e||"auto")[0].offsetWidth,this.$element[e!==null?"addClass":"removeClass"]("collapse"),this},transition:function(t,n,r){var i=this,s=function(){n.type=="show"&&i.reset(),i.transitioning=0,i.$element.trigger(r)};this.$element.trigger(n);if(n.isDefaultPrevented())return;this.transitioning=1,this.$element[t]("in"),e.support.transition&&this.$element.hasClass("collapse")?this.$element.one(e.support.transition.end,s):s()},toggle:function(){this[this.$element.hasClass("in")?"hide":"show"]()}};var n=e.fn.collapse;e.fn.collapse=function(n){return this.each(function(){var r=e(this),i=r.data("collapse"),s=e.extend({},e.fn.collapse.defaults,r.data(),typeof n=="object"&&n);i||r.data("collapse",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.collapse.defaults={toggle:!0},e.fn.collapse.Constructor=t,e.fn.collapse.noConflict=function(){return e.fn.collapse=n,this},e(document).on("click.collapse.data-api","[data-toggle=collapse]",function(t){var n=e(this),r,i=n.attr("data-target")||t.preventDefault()||(r=n.attr("href"))&&r.replace(/.*(?=#[^\s]+$)/,""),s=e(i).data("collapse")?"toggle":n.data();n[e(i).hasClass("in")?"addClass":"removeClass"]("collapsed"),e(i).collapse(s)})}(window.jQuery),!function(e){"use strict";function r(){e(".dropdown-backdrop").remove(),e(t).each(function(){i(e(this)).removeClass("open")})}function i(t){var n=t.attr("data-target"),r;n||(n=t.attr("href"),n=n&&/#/.test(n)&&n.replace(/.*(?=#[^\s]*$)/,"")),r=n&&e(n);if(!r||!r.length)r=t.parent();return r}var t="[data-toggle=dropdown]",n=function(t){var n=e(t).on("click.dropdown.data-api",this.toggle);e("html").on("click.dropdown.data-api",function(){n.parent().removeClass("open")})};n.prototype={constructor:n,toggle:function(t){var n=e(this),s,o;if(n.is(".disabled, :disabled"))return;return s=i(n),o=s.hasClass("open"),r(),o||("ontouchstart"in document.documentElement&&e('